Simple Is Not Easy
Easy is about familiarity and how quickly you can start. Simple is about how few things are braided together. They come apart constantly, and most bad designs are the moment someone chose easy and called it simple.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
This choice made the feature fast to write and the codebase harder to reason about. What is the property I traded away, and what would I call it?
The team needs an audit trail: who changed what, when, on which record. An engineer proposes doing it in an ORM lifecycle hook, which is four lines and works this afternoon.
Use the ORM hook. Four lines, every write path covered automatically, nobody has to remember anything. Simple.
It is easy — near at hand, familiar, quick — and it is not simple, because it braids auditing into every save in the system by a mechanism that is invisible at the call site.
- It is easy — near at hand, familiar, quick — and it is not simple, because it braids auditing into every save in the system by a mechanism that is invisible at the call site.
- Six months later a bulk update bypasses the hook, a data migration produces forty thousand audit rows attributed to nobody, and a test that saves a fixture writes audit history. Each of these is a separate incident and none of them looks like an auditing bug.
- The acting user is not available in the hook, so a request-scoped global appears to carry it, and now auditing is braided into request handling as well (Hidden Global State).
- The reason it is hard to unpick is not that the code is bad. It is that one mechanism now serves two concerns and there is no place to stand where you can see both.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The audit requirement is real and has a compliance deadline, so "do it properly over two sprints" is not on the table.
- The team knows the ORM well and does not know the alternatives.
- Roughly forty write paths already exist, and some of them write in bulk.
- Every change to an audited record produces exactly one audit row, with the acting user recorded.
- A failed transaction leaves no audit row for a change that did not happen.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Something must own "a change happened and here is who made it". That is a domain event, and the domain is where it is known.
- Persistence owns storing rows. It does not own knowing why a row changed, because it cannot know.
- The call site owns the intent — bulk backfills genuinely should not produce user-attributed audit rows, and only the caller knows that.
- The seam is at the point where intent exists. Auditing at the persistence layer is auditing after the intent has been thrown away, which is why the acting user has to be smuggled back in (State Ownership).
- A boundary that a mechanism crosses invisibly is not a boundary; it is a shortcut with a folder around it.
Two properties, two different questions
Easy answers "how quickly can I get going with this?" — a question about the person, the team and the moment. Simple answers "how many things does this tie together?" — a question about the artefact, which stays true when the team changes.
They agree often enough that most engineers never separate them, and the cases where they disagree are exactly the decisions worth arguing about. The audit hook is the archetype: maximally easy, and it braids auditing into every write in the system.
// orm/hooks.ts
onBeforeSave((entity) => {
audit.write(entity.id, diff(entity), currentUserContext.get()) // ambient
})
// braided: persistence + auditing + request identity
// bulk writes bypass it; migrations trigger it; tests trigger it// billing/pause.ts
const result = pause(sub, until, now)
return [result, SubscriptionPaused({ id: sub.id, by: actor, at: now })]
// the caller decides. A backfill returns no event,
// and that is a one-line, obvious, reviewable difference.The hook version is shorter and covers more paths today. It is also the version where "do not audit backfills" has no local answer, because the decision was made in a place that cannot see who is calling. The explicit version pays typing at forty call sites and buys the ability to answer every future exception in one line at the site that knows.
Where the disagreement actually is
Most arguments framed as "simple versus over-engineered" are two people using one word for two properties. One is saying "I can start with this today"; the other is saying "this ties auditing to persistence". Both are true and neither answers the other.
Naming the axis ends the argument faster than winning it does. Once "this is the easy one and here is the braid it creates" is on the table, the remaining question is a cost question — how long does this code live, how many exceptions will arrive — and that is a question the team can answer with evidence.
A compliance reviewer notices that last month's backfill produced forty thousand audit entries attributed to a service account, and asks for them to stop and for the existing ones to be removed.
There is no way to say "not this write" at the write, so a suppression flag goes into the ambient context and every future exception widens it. The hook now contains a list of situations it knows about, which is auditing depending on migrations, tests and jobs.
The backfill does not construct the event. One file, one obvious diff, and no mechanism learned anything about backfills.
The four braids that arrive at every codebase
Cross-cutting requirements arrive with an easy mechanism attached, and the mechanism is nearly always ambient: a hook, an interceptor, a thread-local, a decorator that reads context. Individually each is fine. The fourth one is where reading a single function stops telling you what happens when it runs.
The useful discipline is not refusing them. It is noticing that you are adding one, and asking whether the concern has exceptions — because a cross-cutting mechanism handles zero exceptions well and every exception it acquires becomes a branch in a place that should not know about your callers.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A bulk path is added that bypasses the ORM | Audit rows silently missing for the highest-volume writes | Coverage was a property of one code path, not of the design | Move the decision to where intent exists; assert coverage with a contract test at the boundary (Contract Tests). |
| Multi-tenancy added via an ambient tenant id | A background job runs with whatever tenant was last set | Request-scoped state read from a non-request context (Hidden Global State). | Pass the tenant as an argument on the job's own boundary; make the ambient read fail loudly when unset. |
| Soft delete implemented as a global query filter | An admin report shows wrong totals and nobody can see why from the query | Every query now means something different from what it says | Make deleted-ness part of the model the query asks for, so the call site states its intent (Explicit State). |
| Tracing added by monkey-patching the HTTP client | One client library is patched twice and spans nest wrongly | A mechanism that works by being invisible cannot be composed | Accept it — tracing is the case where ambient genuinely wins, and the cost is bounded because it never has business exceptions. |
How to build it
Most important first.
- Say out loud which property you are buying. "This is easy and not simple, and here is what we will pay" is a legitimate decision; "this is simple" when it is not is how the cost becomes invisible.
- Prefer explicit at the call site over automatic underneath, when the two conflict and the code will live. Explicitness costs typing, and buys the ability to read one function and know what it does (Local Reasoning).
- If you take the easy option under deadline, write the revisit trigger with it: "if a write path ever needs to opt out, or a bulk path appears, this moves" (Revisit Triggers).
- Watch for the tell: a mechanism that requires a global, a thread-local or an ambient context to work is braiding two concerns that did not want to be braided.
- Distinguish the two axes when arguing. "I find that unfamiliar" is a real cost about the team; "that ties three things together" is a claim about the code. Conflating them makes the conversation about competence instead of design.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Hook version, next change: "backfills must not be audited" has no local fix. You add a flag to the global, or a check in the hook that knows about backfills, which is auditing knowing about migration — and every future exception is another branch in the same place.
- Explicit version, next change: the backfill simply does not raise the event. Cost is zero, because the decision was already at the call site.
- The reverse case is honest too: "audit five more entity types" costs nothing in the hook version and five call-site changes in the explicit one. Easy wins that one, and if that is the only change you ever get, the hook was right.
- Explicitness costs real typing and real repetition at call sites, and some of that repetition is duplication you cannot remove without recreating the braid.
- Choosing simple over easy usually means choosing unfamiliar, which slows the team now against a benefit that arrives later and cannot be attributed (The Cost of Change).
- Some braids are worth it. Frameworks, transactions and garbage collection all tie things together invisibly and are overwhelmingly worth their cost; "less braided" is not automatically better.
What can go wrong
- "Simple" is used to mean "small diff", and the argument ends before anyone has said what is braided together.
- The team overcorrects: everything explicit, every call site passing an audit context, and the resulting ceremony is its own accidental complexity (Essential and Accidental Complexity).
- The easy choice is taken deliberately with a trigger, and the trigger is never checked because nobody owns it (Revisit Triggers).
- A design is called simple because it is simple *to use*, while the implementation braids six concerns — which is the ordinary and often correct trade a good library makes, but only if the braid stays inside (Designing a Module Interface).
- The hook version creates a dependency from every write in the system onto the audit mechanism, undeclared and unavoidable.
- The explicit version creates a dependency from a handful of domain operations onto an audit port, declared and refusable.
- Familiarity is a dependency too — on the team that has it. It expires when they leave, which is the part that is never priced (Bus Factor).
- "Simple means less code." A regular expression is short and braids parsing, validation and control flow into one token stream. Line count and simplicity are unrelated (Long Functions).
- "Easy is bad." Easy is a genuine benefit and is often the correct choice, particularly for code with a short life or a hard deadline. The failure is spending it without knowing you did (When Design Does Not Pay).
- "Simple means no dependencies." Simple means few *interleaved concerns*. A dependency with a narrow, honest interface adds nothing to the braid (Do We Need a Package for This?).
- "This is just Rich Hickey's talk." The talk is where the vocabulary comes from, but the useful part is the test: name the things that got tied together, and count them.
- hidden-global-state
- temporal-coupling
Testing it, and how it ages
- Test that a bulk path produces the audit rows you intended — the failure mode is a missing row, and missing rows are what tests are worst at noticing unless you assert the count.
- Test the audit rule without a database, which is only possible if it is not a persistence hook. The difficulty of writing this test is the design feedback (Testing as Design Feedback).
- A test that saving a fixture in a test does *not* write audit history, because that is the surprise that costs an afternoon later.
- Easy decays fastest. Familiarity is a property of the current team; the braid is a property of the code, and it outlives them.
- Simple designs get more valuable as the number of concerns grows, because the cost of a braid is roughly the product of what it ties together, not the sum.
- The pressure to re-braid is constant: every new cross-cutting requirement — auditing, tracing, tenancy, soft delete — arrives with an easy mechanism attached, and the fourth one is where the codebase becomes hard to reason about.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe distinction is about how many concerns a mechanism ties together, which is observable in any language — though what counts as "near at hand" varies enormously by team, so the easy half of the pair is always local.
- FRAMEWORK-SPECIFICFrameworks with lifecycle hooks, ambient contexts and convention-based wiring make the easy option dramatically easier, so the braid forms faster there than in a framework where you pass everything explicitly. The advice does not change; the default you are fighting does (What a Framework Charges).
- CONTESTEDThe strongest opposing view is that this distinction is used as a rhetorical device to dress up personal preference: any design can be described as braiding things together, "simple" is not measurable, and in practice teams ship more with the familiar tool than with the theoretically-decomposed one. That objection lands hard against the vaguer uses of the word — the defence is to require the concrete list of what is tied to what, which either exists or does not.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — a mechanism that works by being ambient is also a mechanism your tests get for free and cannot turn off, which is why test fixtures start producing production-shaped side effects.