Architecture Anti-Patterns
Recognizing what goes wrong: the big ball of mud, the distributed monolith, god objects, the golden hammer, shared databases between services, and how to get out of each.
Why anti-patterns happen
An anti-pattern is a common response to a recurring problem that looks like a solution but causes more harm than it prevents. The 1998 book AntiPatterns (Brown, Malveau, McCormick and Mowbray) popularized the idea for software, with a catalog that includes The Blob, Golden Hammer, Lava Flow and Spaghetti Code. A good description of an anti-pattern names its symptoms, explains why people fall into it, and describes a way out, often called the refactored solution.
The "why" matters. Nobody sets out to build a big ball of mud. Each anti-pattern on this page grew from a decision that made sense at the time: a deadline, a team that knew one tool well, a shortcut that saved weeks. Blaming the people who made those decisions teaches nothing; understanding the forces tells you how to stop it happening again. The running example is Kestrel Energy, whose billing system KBS (Kestrel Billing System), customer portal and meter-api show several of these patterns, as most real systems of their age do.
The big ball of mud
Brian Foote and Joseph Yoder named the most common architecture of all in a 1997 paper: "A BIG BALL OF MUD is haphazardly structured, sprawling, sloppy, duct-tape and bailing wire, spaghetti code jungle." A big ball of mud is a system without a recognizable structure: every part may call every other part, data is shared freely, and nobody can say where one responsibility ends and the next begins. Its organization, they wrote, "if one can call it that, is dictated more by expediency than design."
Their paper is unusually fair to the mud. It describes the forces that produce it as patterns in their own right: throwaway code that was never thrown away, piecemeal growth, "keep it working" under constant pressure. A big ball of mud often wins because it ships. KBS is a textbook case: more than a decade of tariffs, invoices, payments, dunning and moving-house rules added one at a time, each change made in the fastest place, and every year-end billing run a reason not to touch anything.
One symptom you can measure is cycles between modules: A uses B, B uses C, C uses A. In a cycle, no part can be understood, tested or moved on its own. This script finds cycles in KBS's package dependencies:
# Which KBS packages import which (collected from the import statements)
depends_on = {
"accounts": ["invoicing"],
"dunning": ["payments", "letters"],
"invoicing": ["tariffs", "accounts"],
"letters": ["accounts"],
"payments": ["invoicing"],
"tariffs": ["contracts"],
"contracts": ["accounts", "tariffs"],
}
def find_cycles(graph):
cycles, path, done = [], [], set()
def visit(node):
if node in path: # back on our own trail: a cycle
cycle = path[path.index(node):]
start = cycle.index(min(cycle)) # rotate so each cycle prints once
cycles.append(cycle[start:] + cycle[:start])
return
if node in done:
return
path.append(node)
for target in graph.get(node, []):
visit(target)
path.pop()
done.add(node)
for node in sorted(graph):
visit(node)
return sorted(set(map(tuple, cycles)))
for cycle in find_cycles(depends_on):
print(" -> ".join(cycle + (cycle[0],)))
accounts -> invoicing -> accounts accounts -> invoicing -> tariffs -> contracts -> accounts contracts -> tariffs -> contracts
Accounts, invoicing, tariffs and contracts form one knot: none of them can be changed or extracted without the others. A depth-first search like this one reports at least one cycle for every knot, not necessarily every possible cycle, which is enough to know where to start untangling.
The way out is not a rewrite. Draw boundaries inside the mud first: decide which modules there should be (the bounded contexts are a good guide), break the cycles one dependency at a time, and protect each boundary with a fitness function so it does not grow back. The result is a modular monolith, which is what Kestrel chose for KBS (ADR-0003). Foote and Yoder, and Eric Evans' DDD Reference after them, also describe the pragmatic option: if a muddy area is not worth fixing, draw a boundary around the whole mess, keep new code out of it, and talk to it only through a translation layer.
The distributed monolith
Imagine Kestrel splitting KBS into separate services to escape the mud, but keeping the boundaries and the database as they are. A distributed monolith is a system deployed as separate services that cannot actually change, deploy or fail independently: they share a database, call each other synchronously in long chains, or must be released together. You pay every cost of microservices (network calls, more things to deploy, monitor and secure, harder debugging) and get none of the benefits.
The chain is also an availability problem: if each service is up 99.9% of the time, a chain of four that needs all of them is up only about 99.6% of the time (see series and parallel availability). The clearest symptom, though, is in the release history. If services are always released together, they are one deployable that happens to be cut into pieces. Kestrel's team ran this check on a design study of the split, using the releases the old KBS would have needed:
from collections import Counter
from itertools import combinations
# Services in each production release of a (hypothetical) split-up KBS
releases = [
{"tariff-svc", "invoice-svc", "export-svc"},
{"invoice-svc", "export-svc"},
{"payment-svc"},
{"tariff-svc", "invoice-svc", "export-svc"},
{"customer-svc", "invoice-svc", "export-svc"},
{"invoice-svc", "export-svc", "payment-svc"},
{"tariff-svc", "invoice-svc", "export-svc"},
{"customer-svc"},
]
alone = Counter()
together = Counter()
deployed = Counter()
for services in releases:
deployed.update(services)
if len(services) == 1:
alone.update(services)
together.update(combinations(sorted(services), 2))
print("Released alone:")
for service in sorted(deployed):
print(f" {service:13} {alone[service]} of {deployed[service]} releases")
print("Pairs released together in 3 or more releases:")
for (a, b), count in sorted(together.items()):
if count >= 3:
print(f" {a} + {b}: {count} times")
Released alone: customer-svc 1 of 2 releases export-svc 0 of 6 releases invoice-svc 0 of 6 releases payment-svc 1 of 2 releases tariff-svc 0 of 3 releases Pairs released together in 3 or more releases: export-svc + invoice-svc: 6 times export-svc + tariff-svc: 3 times invoice-svc + tariff-svc: 3 times
export-svc and invoice-svc were never released on their own, and always together: by their release history they are one service. Results like this fed into ADR-0003, which turns KBS into a modular monolith instead of splitting it into services.
The way out goes one of two directions. Either merge services that always change together back into one deployable (often the right answer, and no failure), or make them truly independent: give each its own data, replace synchronous chains with asynchronous messages where the business allows a delay, and protect each interface with versioned contracts so one side can release without the other. "Microservices fix a big ball of mud" is a myth: splitting mud without new boundaries gives distributed mud.
God objects
A god object is a class or module that knows or does too much: it holds most of the data, contains most of the logic, and every other part of the system depends on it. The AntiPatterns book calls it The Blob. It is the code level of the big ball of mud, and the opposite of high cohesion (see Design Principles): when one class handles logins, tariffs and dunning letters, every change in any of those areas touches the same file, and every developer works in it at once.
KBS's CustomerManager has 3,800 lines. A rough check
counts methods per class and how many business areas their names
touch:
import ast
# A shortened stand-in for kbs/customer.py (the real file has 3,800 lines)
source = '''
class CustomerManager:
def login(self, email, password): ...
def reset_password(self, email): ...
def calculate_advance(self, contract): ...
def create_invoice(self, contract, period): ...
def send_invoice_email(self, invoice): ...
def register_payment(self, iban, amount): ...
def start_dunning(self, customer): ...
def move_house(self, customer, new_address): ...
def export_bank_file(self, date): ...
class EAN:
def __init__(self, code): self.code = code
def __str__(self): return self.code
'''
# Words in method names, mapped to the part of the business they belong to
AREAS = {"login": "identity", "password": "identity", "advance": "tariffs",
"invoice": "invoicing", "payment": "payments", "dunning": "payments",
"bank": "payments", "move": "contracts"}
for node in ast.parse(source).body:
if isinstance(node, ast.ClassDef):
methods = [f.name for f in node.body if isinstance(f, ast.FunctionDef)]
areas = {area for word, area in AREAS.items()
if any(word in name for name in methods)}
verdict = "god object?" if len(methods) > 7 or len(areas) > 2 else "ok"
print(f"{node.name}: {len(methods)} methods, {len(areas)} areas "
f"{sorted(areas)} -> {verdict}")
CustomerManager: 9 methods, 5 areas ['contracts', 'identity', 'invoicing', 'payments', 'tariffs'] -> god object? EAN: 2 methods, 0 areas [] -> ok
The thresholds are arbitrary and the question mark is deliberate: a metric can point at a suspect, not convict it. A name such as "Manager", "Helper", "Util" or "Service" without a noun from the business is a hint too, because it says nothing about what the class is responsible for.
The way out is a series of refactorings: find groups of methods and fields that are used together, extract each group into its own class in the module where it belongs (identity, tariffs, invoicing, payments, contracts), and leave the old class as a thin facade that delegates, until its callers have moved. Write characterization tests before the first step; a god object is rarely well tested.
Shared databases between services
A shared database (also called an integration database) is one database whose tables are read and written directly by several separately developed applications or services, which then use the tables as their interface to each other. It is the fastest way to integrate two systems, which is why it is everywhere. At Kestrel, the portal, meter-api and KBS all use db01.
The shared database couples its users in two ways. The first is at design time, through the schema: every table is a contract that nobody wrote down. When a KBS release renames a column, it is not KBS's tests that fail but the portal, in production:
CREATE TABLE invoice (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
period TEXT NOT NULL,
amount_cents INTEGER NOT NULL
);
INSERT INTO invoice VALUES
(1, 20417, '2026-08', 8450),
(2, 20417, '2026-09', 8450),
(3, 31002, '2026-09', 6200);
-- The portal's query, written by the portal team in 2019
SELECT period, amount_cents FROM invoice WHERE customer_id = 20417 ORDER BY period;
-- A KBS release makes it explicit that amounts include VAT
ALTER TABLE invoice RENAME COLUMN amount_cents TO amount_incl_vat_cents;
SELECT period, amount_incl_vat_cents FROM invoice WHERE customer_id = 20417 ORDER BY period;
-- The next morning: the portal's query, unchanged
SELECT period, amount_cents FROM invoice WHERE customer_id = 20417 ORDER BY period;
period amount_cents ------- ------------ 2026-08 8450 2026-09 8450 period amount_incl_vat_cents ------- --------------------- 2026-08 8450 2026-09 8450 Error: no such column: amount_cents
KBS's own tests passed, because KBS updated its own query. Hamid Rahimi, the DBA, describes the everyday version of the problem: he cannot change a billing column without asking three teams, so columns are never changed, only added, and the schema slowly fills with fields nobody dares to remove.
The second coupling is at run time. All users share one server's connections, locks, memory and disk. During INC-22047 on 6 October 2026, a new meter-api release leaked database connections on db01; the portal, which had done nothing wrong, slowed down as well. The shared database did not cause the bug, but it widened the blast radius from one service to three. One of the follow-up actions was an alert on db01 connections per client (REQ-6120), a sensible measure that treats the symptom; the proposed ADR-0007 (see Documenting Architecture) addresses the structure.
The way out is to give each table one owner. Other services get the data through the owner's API or through events that the owner publishes, ideally with a transactional outbox so the database write and the event cannot drift apart (see Event-Driven Architecture and Data in Distributed Systems). Until that is done, read-only database views that the owner maintains as a published contract are a useful intermediate step: the owner can rename a column and keep the view stable.
The golden hammer
"It is tempting, if the only tool you have is a hammer, to treat everything as if it were a nail," wrote the psychologist Abraham Maslow in 1966. The golden hammer is using one familiar technology, pattern or tool for every problem, whether it fits or not, because the team knows it well or because it was a success last time. At Kestrel, for years, every integration was a cron job that wrote a CSV file: fine for the nightly bank file, painful for anything that needed to happen within minutes. Its mirror image appears in teams that put everything on Kubernetes, or make every piece of logic an event.
Familiarity is a real advantage: a team that knows a tool deeply runs it more reliably than a team learning something new. The mistake is not using a known tool; it is never asking whether the problem calls for something else.
Resume-driven development
Resume-driven development is choosing a technology mainly because it is interesting or looks good on the developers' résumés, rather than because the problem needs it. It is the golden hammer's opposite: not one old tool for everything, but a new tool for its own sake. A 2021 paper by Fritzsch and colleagues, presented at the International Conference on Software Engineering, gave the term an academic definition and connects it with hiring practices that reward trendy technologies, so the fix is partly organizational, not only technical.
At Kestrel a proposal arrived to rewrite the portal in a new framework with an event-sourced database. Margriet de Wit, the chief financial officer, asked one question: "Where is the ADR?" Written down with its context and considered options, the proposal could not name a quality goal the current portal failed. Both anti-patterns have the same cure: make technology choices against explicit quality attributes, and record them as architecture decision records with the options that were considered.
Lava flow
A lava flow is code that nobody understands, nobody uses (or nobody is sure about), and nobody dares to remove: leftovers of experiments, abandoned features and half-finished migrations that hardened in place like cooled lava. KBS still contains a complete module for a 2016 loyalty-points scheme that was never launched. Every developer who reads it loses time, every upgrade must keep it compiling, and nobody knows whether some forgotten report still calls it.
Lava flows are a side effect of changes that are never finished. The branch by abstraction and strangler fig steps both end with "delete the old", and that step is the one most often skipped when the next project starts. The way out: find out whether the code runs (logs, metrics, a counter or a log line in the suspect path for a full billing cycle, coverage data from production-like tests), then delete it. Version control keeps it, if anyone ever needs it back.
How to get out: an overview
Each anti-pattern has its own exit, but they share a shape: name the problem, draw the boundary you want, enforce it automatically, and move one piece at a time while the system keeps running.
| Anti-pattern | Symptom to look for | Way out |
|---|---|---|
| Big ball of mud | Cycles between modules; every change touches many files | Module boundaries and fitness functions; a modular monolith |
| Distributed monolith | Services always released together; long synchronous call chains | Merge them, or make them independent with own data, messaging and contracts |
| God object | One class in every change; "Manager" in the name | Extract classes by responsibility (Design Principles) |
| Shared database | Schema changes need several teams; one client slows all | One owner per table; API, events or published views (Data in Distributed Systems) |
| Golden hammer, resume-driven development | Technology chosen before the problem is described | Quality goals first; ADRs with considered options (Documenting Architecture) |
| Lava flow | Code nobody can explain or remove | Measure whether it runs, then delete it; finish every migration (Evolving Architecture) |
Most of these anti-patterns are found the same way problems in operations are found: through incidents, slow changes and frustrated people. When a postmortem lists a structural contributing factor ("the shared database spread the impact"), that is an architecture finding, and it belongs in the debt register and the next architecture review, not only in the incident's action items.