Learn Software Engineering & Design
How changing requirements become software that stays understandable, testable and safe to change. Thirty-eight modules, from what actually makes code hard to change to designing systems that call a model.
Engineering Fundamentals
7 lessonsWhat actually makes software hard to change, and why the cost of the next change — not elegance, not pattern count — is the thing design is optimising.
Not size, not age, and not ugliness. A change is expensive when the knowledge it touches is spread across places that do not know about each other.
Design decisions are bets on which changes arrive. The bet is priced in indirection now against edits later, and it can lose.
Requirement, constraints, invariants, responsibilities, boundaries, interfaces, state, dependencies, failure, implementation, tests, feedback, evolution — in that order, because each answer constrains the next.
Three different grains, three different reversibility profiles. Confusing them is why teams argue about folder layout as though it were a scaling decision.
Whether you can understand one piece of code without loading the rest of the system into your head. It is the property that decides how a codebase feels to work in.
Design has no intrinsic virtue. Every structural claim in this domain has to cash out as a change that got cheaper, or it is decoration.
Structure is an investment against future change. Where there is no future, it is pure cost — and knowing which code that is, is part of the skill.
Requirements
6 lessonsDesign starts from what must be true, not from choosing a pattern. Functional and non-functional requirements, and the constraints a valid design cannot ignore.
Design starts from what must be true, not from picking a structure. Seven questions decide almost everything that follows, and only one of them is about the happy path.
"A user can create an order" fits inside almost any structure. "Order creation is idempotent and auditable" fits inside very few — which is why the second kind decides the design.
The database that already exists, four engineers, a date, a compliance regime and a legacy integration are not obstacles in front of the design. They are inputs to it, and a design that ignores them is not a design.
Timezones, concurrency, partial failure, retention and tenancy are almost never written in the ticket, are almost always real, and are structural — which is the worst combination available.
You were handed today's version. Which parts of it are stable and which are volatile is not a product question — it is the design input that decides what you hide behind what.
You cannot design for requirements you do not have. You can design for the ones you do, and write down the assumptions you made — which is the difference between a decision and a habit.
Invariants
5 lessonsThe things that must never stop being true, and the engineering question that follows: which layer is actually responsible for protecting each one.
A balance that cannot go negative, a username that is unique, an order that cannot ship before payment, a tenant that cannot see another tenant. These are not features — they are the properties everything else is built on top of.
Every invariant is enforced somewhere specific — a type, domain logic, a transaction, a database constraint, an API contract — and the design question is which, because each covers a different set of paths at a different price.
Types, runtime guards, database constraints and tests are four different mechanisms with four different coverages. Using all four is defence in depth, and it means four places to keep in sync — which is a trade, not a free win.
The rule is enforced in the service. Then a background job, an admin tool or a migration writes straight to the table. This is the characteristic failure of the whole module, and it is an ownership failure before it is a technical one.
Which set of things must change together, atomically, for an invariant to hold. Answer that and you have chosen your aggregates, your transactions and — later — where a service could ever be split.
Problem Decomposition
7 lessonsSplitting a problem by responsibility rather than by folder name — and the single-responsibility idea stated as "a coherent reason to change" rather than "one thing".
Split a problem into responsibilities and give each one an explicit interface. Splitting it into folders named after technical types is not decomposition — it is filing.
Ask of every unit: what is this responsible for? If the answer needs the word "and" more than once, you have found the design problem before it found you.
Not "a class does one thing" — that phrasing has no content. "A module should have one coherent reason to change" has some, and even then the hard cases are genuinely ambiguous.
Transport, business logic, persistence, formatting and infrastructure change for different reasons, so mixing them is expensive. Adding a layer for each of them anyway is a different and equally expensive mistake.
The anti-lesson. Splitting by technical type puts every file of a kind together and every file of a feature apart, producing boundaries that no requirement respects.
Three probes locate where a boundary should go: follow the change, follow the invariant, follow the rate of change. All three are questions about evidence, not taste.
Ten files that must all be read together are worse than one file that need not be. Splitting has a cost, it is paid by every future reader, and nothing about it is free.
Abstraction
6 lessonsAn abstraction hides detail behind a useful model, and charges indirection, vocabulary and leakage for it. When to pay, and when duplication is cheaper.
A useful model that lets a caller ignore something specific. `PaymentGateway.charge()` hides provider HTTP; `UtilityManagerFactoryHelper` hides nothing and is therefore not an abstraction at all.
Indirection, vocabulary, learning cost and leakage — charged to every future reader, forever, whether or not the flexibility is ever used. Do not abstract by default.
A heuristic about evidence, not a counting rule. Duplicate until the pattern is a pattern, because the third case is usually the first one that shows you which parts actually vary.
`Repository.save()` claims database independence while transaction scope, isolation level, index behaviour and failure modes come straight through. An abstraction hides a mechanism; it cannot erase the physics underneath it.
The wrong abstraction costs more than the duplication it replaced, because duplication is visible and a wrong shared unit is not. Its signature is callers that diverged and a parameter list that grew flags to hold them together.
An abstraction buys flexibility along one axis and pays along every other. Which axis you pick is not a detail of the design — it is the design.
Encapsulation & Information Hiding
5 lessonsKeeping implementation behind stable boundaries, and the sharper idea underneath it: hide the decisions most likely to change.
Callers reach state through operations that can enforce a rule, rather than through the data itself — so the rule has somewhere to live.
Parnas's real argument: decompose around the decisions most likely to change, so that when one changes it changes inside one module.
The interface is what a caller must know. Design it from what the caller is trying to do, not from what the module happens to have lying around.
Three quiet ways a boundary leaks: a getter that returns mutable internals, a public field that becomes a contract, and an interface that grew to mirror its only implementation.
A boundary is worth depending on when it changes more slowly than what sits behind it. If it changes at the same rate, it is a forwarding address.
Cohesion & Coupling
7 lessonsThe five kinds of coupling that actually differ in cost, why cohesion is the other half of the same question, and how fan-in, fan-out and cycles show up in a real dependency graph.
A module is cohesive when its parts change for the same reason. Cohesion and coupling are one question asked twice: what belongs together, and what may know about what.
Data, control, temporal, shared-state and implementation coupling are not degrees of one thing. They differ by an order of magnitude in cost, which is why the taxonomy is worth having.
When calls must happen in an order the type system does not know about, the ordering lives in someone's head — and heads leave.
Two modules connected through a mutable structure neither of them owns. The most expensive kind, because there is no list of who writes to it.
How many modules depend on this one, and how many does it depend on. Both are signals about where change lands — and neither, on its own, is a verdict.
A cycle turns three modules into one. You cannot reason about, test, initialize, extract or delete any of them without the others.
Who depends on me, and what do I depend on. A useful pair of questions, a widely published pair of metrics, and a gap between the two that is worth being honest about.
Dependency Design
7 lessonsDirection, inversion and injection — taught as three different ideas rather than one, because conflating them is how teams end up with a container and no boundaries.
Stable, high-level policy should not depend unnecessarily on volatile, low-level detail. The word "unnecessarily" is doing almost all the work.
Business policy defines the interface; infrastructure implements it. Both arrows point at the middle — and none of this requires a container.
An object receives its collaborators instead of constructing them. That is the whole idea, it needs no library, and it is the third of three things people call DIP.
Take collaborators as constructor parameters and the type system enforces that a constructed object is a usable one. No framework required, and the parameter count is a design signal.
A global registry objects pull dependencies out of. It makes dependencies implicit — which defeats local reasoning and moves whole classes of error from compile time to run time.
Somebody has to construct the object graph. Doing it in one deliberate place is the design decision; doing it with a container is a separate, later, optional one.
Only some dependencies are worth inverting: the ones that change, that are slow, that have side effects, or that are non-deterministic. The rest should be called directly.
SOLID, Critically
7 lessonsEach principle by the problem it addresses, a real example, a real misuse and a counterexample. Useful heuristics, not laws, and the difference matters.
Five heuristics about the cost of change, collected over two decades, of genuinely unequal quality — and none of them a law.
"A coherent reason to change" is the useful reading. "One thing" is the one that produces a hundred classes that each do nothing.
Prefer designs where common, observed variation can be added without rewriting stable core logic. Not "never modify existing code" — that reading builds plugin machinery for variation that never arrives.
A subtype must keep every promise callers rely on from the abstraction. It is a behavioural contract, not a fact about inheritance syntax — and it is the sharpest of the five.
A consumer should depend on the smallest contract that serves it. That is a statement about coupling, not a rule that interfaces must be small.
The idea is which module declares the interface. The cargo cult is a container, an interface per class, and the same dependency graph as before.
An interface per class, layers that pass data unchanged, abstractions with one implementation — and the critiques of SOLID that are strong enough to deserve a straight answer.
Composition & Inheritance
5 lessonsWhy composition avoids rigid hierarchies, when inheritance genuinely earns its place, and polymorphism as a response to variation that is actually there.
A hierarchy picks one axis of variation forever. A field can be swapped. That reversibility — not elegance — is the whole argument.
Real substitutability, a closed and stable set of subtypes, and shared behaviour that is genuinely the same behaviour. Miss any one and you have coupled two types to save typing.
One interface, several implementations, chosen because the behaviour genuinely differs. With one implementation it is not polymorphism — it is a redirect with a type on it.
Depend on the narrowest thing that does the job. That is a different rule from "declare an interface for everything", and it more often means asking for less than for an abstraction.
Four languages, one problem: share behaviour without spending the inheritance slot. Each solution picks a different thing to give up, and knowing which tells you what the code will do under change.
Design Patterns
11 lessonsEach pattern as a response to an observed problem, with its trade-off and its simpler alternative — because a pattern applied without the problem is just indirection.
Their durable value is a shared name for a shape you already built. Treated as a construction kit instead of a naming scheme, they add structure nobody needed.
Behaviour that varies by a recurring kind, held in a value instead of a conditional. Worth it when the variation is real and stable — and a map of functions usually gets you there first.
Encapsulates a construction decision that callers should not make. When there is no decision, a factory is a function that calls `new` and charges you a file for it.
Your interface on one side, someone else's on the other, and a translation in between. The most consistently useful pattern here, because it is an anti-corruption layer in miniature.
A small interface over a subsystem whose full surface most callers do not need. Useful when the subsystem is genuinely complex and dangerous when the facade becomes the only way in.
Add behaviour by wrapping rather than by editing. Excellent for logging, caching and retry; the cost is a stack trace nobody can read and behaviour that depends on wrapping order.
The producer stops knowing its consumers. That is the point and the price: nothing in the code shows what happens when the event fires.
An action turned into a value. Pointless if you only intend to call it — and genuinely load-bearing the moment you need to queue, retry, audit, schedule or undo it.
Behaviour that varies by lifecycle state, held in a type per state. Often the right instinct — and an explicit state machine is usually the clearer way to satisfy it.
A base class fixes the steps and subclasses fill in two of them. It works, and passing the two steps in as functions does the same job without a hierarchy.
The anti-lesson. Abstractions with one implementation, event buses for local calls, factories of factories — structure added for problems nobody has, and it is not free.
Domain Modeling
10 lessonsEntities, value objects, aggregates and services — plus the honest question of whether a rich domain model earns its cost over a transaction script for the system in front of you.
Getting the nouns and verbs the business actually uses into the code, so a requirement in their sentence maps to a change in one of yours.
One word for one concept, in conversation and in code — where the concept is real. The failure is three names for one thing across three modules, all of them defensible.
Some things are the same thing after every one of their fields has changed. Order #123 is still order #123 — identity, not equality, is what defines them.
Things defined entirely by their value — Money, EmailAddress, Coordinates. The highest-value, lowest-cost idea in this module, and the one worth adopting even if you take nothing else.
A consistency boundary drawn around state that must change together. Powerful and easy to over-apply — most objects are not aggregates and should not be treated as one.
One door into the boundary. External changes go through the root so the invariant has exactly one place it can be checked — and exactly one place it can be bypassed.
For the operations that genuinely belong to no single entity or value object. A small, useful category — and a dumping ground the moment it stops being small.
Data objects with no behaviour, and all the logic in services. Widely called an anti-pattern, widely defended, and correct more often than either side admits.
One procedure per operation, top to bottom, doing the whole job. Often exactly right — and the criteria for when it stops being right are knowable in advance.
The machinery costs vocabulary, indirection and mapping on every change. It repays only at real domain complexity, with real access to someone who knows the domain.
State & State Machines
7 lessonsMaking lifecycle explicit, so that invalid transitions become impossible to express rather than merely undesirable — and boolean-flag explosion becomes visible.
Name the states a thing can be in instead of inferring them from combinations of fields. The inference is a rule, and an unwritten rule is enforced by memory.
States, transitions, guards and effects as a table the code reads — so the lifecycle is data you can review rather than control flow you have to reconstruct.
The moves that must not exist are part of the design. A comment saying "do not cancel after delivery" is a hope; a transition table that has no such row is a rule.
Four independent booleans describe sixteen states. Five are legal. The other eleven are not prevented by anything, and the arithmetic is the whole argument.
Which module is allowed to mutate this piece of domain state — and what it means that the answer is currently "any of them".
A model where `status = "paid"` with `paidAt = null` cannot be written at all. Powerful where an invariant justifies it — and easy to overdo on a model that has no such invariant.
Say "this may be missing" in the type where the language supports it, and never encode absence as an empty string, a zero, a sentinel date or a magic id.
Error Modeling
6 lessonsFailure as part of the design: separating expected business failure from validation, dependency failure and outright bugs, and choosing how each is expressed in types.
Failure is part of the design, not an appendix to it. Expected business failure, validation failure, dependency failure and programming bug are four categories with four different correct responses.
Six kinds — InvalidInput, NotFound, Conflict, Unauthorized, DependencyTimeout, InternalBug — chosen because each one gets a different response. The taxonomy is a type-level decision, not a status-code table.
`Result<Payment, PaymentError>` puts expected failure in the return type, where the compiler can insist somebody deals with it. What that costs depends enormously on the language.
A non-local jump is exactly right for a failure nobody local can answer, and exactly wrong for an outcome the caller was supposed to decide about. The dividing line is not a rule about exceptions.
Every failure has a point where it stops being handled locally and becomes somebody else's problem. Choosing that point deliberately is a design decision; discovering it in production is not.
`catch {}` is the visible version. The interesting one is an interface that can only return success or failure, so the code that half-worked has nowhere honest to put the truth.
Side Effects & Immutability
7 lessonsPure computation versus I/O and mutation, the functional-core / imperative-shell split, and what immutability buys and costs.
Computation returns a value; an effect changes something. Database writes and network calls are the obvious ones — clocks and randomness are the two that make a function look pure and behave otherwise.
Gather the inputs, decide with pure logic, then perform the effects the decision asked for. It makes the interesting part trivially testable, and it charges you for fetching data you might not need.
A value that cannot change is a value you can reason about once. That buys local reasoning and cheap change detection, and it charges copying, allocation and awkwardness in the places that genuinely want to mutate.
Mutation is not the problem. Mutation of something with no clear owner is. Local ownership, a stated lifecycle and a boundary it does not cross make it the right design surprisingly often.
A value reachable from everywhere is a dependency nobody declared. It shows up as tests that pass alone and fail together, functions whose behaviour depends on what ran first, and a concurrency bug you cannot reproduce.
A pure function needs no setup: you call it and assert. Every line of setup a test requires is the design telling you what that code depends on, which makes test friction the cheapest design signal available.
Push effects to the edges so the middle can be reasoned about — and know the limit: some domains are effects all the way down, and there the honest design is to make each effect a modelled step rather than to pretend there is a pure core.
Naming & Function Design
6 lessonsNames are the API you present to every future reader. Units, roles, side effects — and function design judged by responsibility rather than line count.
A name is the interface every future reader uses instead of the body. It has to carry domain meaning, role, units and whether calling it changes anything.
A bare number carries no unit, so the unit lives in someone's head. Three rungs — comment, suffix, type — with escalating cost and escalating safety.
`sendEmail(user, true, false)` is unreadable at the call site, and the call site is where every future reader meets it. The fix is a type, not a comment.
A function is judged by what it needs, what it returns, what it changes, how many reasons it has to change, and whether its name is true. Length is not on the list.
Long is not automatically bad. Ask whether it mixes responsibilities, whether the control flow can be followed, and whether it hides concepts that deserve names.
When code uses the words the business uses, a reader can apply domain knowledge instead of tracing execution. That is the difference between reading and deducing.
Documentation
5 lessonsComments that explain why, decision records that survive the author, and the failure mode nobody plans for: documentation that has quietly become wrong.
The code already says what it does. A comment earns its place by saying why, what constrains it, and what a reader would otherwise reasonably assume and be wrong about.
API docs, ADRs, runbooks, READMEs, domain rules and migration notes are six different products with six different audiences and six different decay rates.
Documentation is trusted, so wrong documentation is acted on with confidence. That makes a stale document worse than no document — and nothing fails when one goes stale.
Context, decision, alternatives, consequences — written once, never edited, superseded when it changes. The only document that cannot decay, because it describes the past.
Proximity and ownership are the only defences against decay that work without discipline. What belongs in the code, what belongs in the repository, and why the wiki entry is the one that rots.
Code Review
6 lessonsWhat review is actually for once tooling handles formatting, what a useful checklist asks, and why review size predicts defect detection better than reviewer skill.
Correctness, design, maintainability, security and knowledge sharing — five things a person can do. Formatting is not one of them, because a tool already did it.
Six questions, in the order attention runs out: behaviour against requirement, invariants moved, failure modes added, simpler alternative, meaningful tests, and whether you could debug it at three in the morning.
The same reviewer finds real problems in a sixty-line change and waves through a nine-hundred-line one. Diff size predicts what review catches better than almost anything else about the reviewer.
Critique the code and what it will cost, never the person. Then the two harder halves: disagreeing with someone senior, and taking twenty comments on work you were proud of.
Formatters, linters, type checkers, tests and static analysis exist so that human attention is spent only on what is arguable. Every mechanical comment a person still makes is a missing rule.
By the time a change reaches review the boundary already exists. That is why "this belongs somewhere else" is the comment most often agreed with and least often acted on.
Refactoring
9 lessonsChanging internal structure without changing observable behaviour — as a disciplined loop with a safety net, not a rewrite that someone called a refactor.
Changing internal structure without intentionally changing observable behaviour. Almost everything called a refactor is something else, and the difference is what makes it safe.
Working code, a safety net, one small transformation, verify, repeat. The discipline is entirely in the size of the step and in never being more than one step from working.
Extract to give a meaningful concept a name, not to reduce a line count. The two motivations produce different code, and only one of them helps.
Pull out a module when a set of responsibilities has become cohesive enough to have its own reasons to change — and its own interface that hides them.
The highest value-to-risk refactoring there is, and the most neglected. A better name is a better model, and the cost is usually one command.
If a piece of logic spends its time reaching into another object's data, it probably belongs to that object. Moving it is usually the cheapest coupling reduction available.
Worth doing when the variation is stable, meaningful and repeated across several operations. Not worth doing to most switch statements, where the switch is clearer than what replaces it.
Bundle arguments that travel together and mean something together. Bundling them into a vague `Options` bag because there were too many is how a long parameter list becomes an untyped one.
Sometimes you have to change code whose behaviour nothing protects. The technique is a small number of provably-safe moves, used to buy a seam, used to get a characterization test in place.
Code Smells
9 lessonsHeuristics that point at a possible design problem, each with the case where it is genuinely fine — because a smell is a question, not a verdict.
A named pattern that raises a question about the design. Roughly half the time the honest answer is "this is fine", and a smell that cannot say when is just taste with a job title.
One type with a huge API, a dozen dependencies and a dozen unrelated reasons to change. The finding is the reason count, not the line count — and the fix is rarely a six-way split.
One requirement, seven modules, none of which is about that requirement. The code is not badly written — the knowledge has no owner, so every consumer had to learn it.
One module, many unrelated reasons to change. The exact dual of shotgun surgery: there the knowledge had no home, here one home holds knowledge that does not belong together.
A function that reaches into another module's data far more than its own. Sometimes the behaviour is in the wrong place; sometimes the other module is a value type and this is exactly right.
Money as `number`, email as `string`, a user id as `int`. Meaningful types move a class of mistake from runtime to compile time — and not every string needs a wrapper.
Eight arguments, four of them booleans. Bundling them into a parameter object makes the call site tidier and changes nothing — the finding is usually a concept that has no name.
`utils.ts` is not a module, it is the absence of one. Its contents are the pieces of the domain nobody could find a home for, and it grows because it never says no.
Two identical blocks may not be the same concept, and two blocks that look nothing alike may encode the same rule. Textual similarity is the wrong test, and it is the one everybody uses.
Technical Debt
5 lessonsA choice that raises the cost of future change. Deliberate and bounded is a strategy; accidental and unnamed is the thing that compounds.
A design or implementation choice that increases the cost of future change. Not ugliness, not old code, not a library you would not have picked — and the metaphor was originally about deliberate shortcuts.
A shortcut taken knowingly is a strategy — if the trade-off is understood, the impact is bounded, and someone knows the way out. Two of those three are usually missing.
Debt nobody chose: an unclear domain, requirements that moved, a rushed design, missing tests, people who left. It is the larger half, and it is not a moral failure.
The shortcut is the principal. The interest is that every later change in that area costs more — and because later changes are built on earlier ones, the cost grows rather than staying flat.
Five fields per entry — Problem, Impact, Risk, Owner, Trigger — and a hard rule about size. A register with two hundred entries is not a register, it is a wishlist nobody reads.
Legacy Code
7 lessonsCode that is risky to change because its behaviour is poorly understood or weakly protected — and the characterization-test, seam, small-step loop that makes it safe.
Legacy code is code that is risky to change because its behaviour and assumptions are poorly understood or weakly protected. Age is a correlate, not the definition.
Tests that record what the code does today — including what it does wrongly — so that a later change has a baseline to be measured against. They assert behaviour, not correctness.
A seam is a place where you can change behaviour, or substitute a dependency, without editing the code at that place. Finding one is what makes untestable code testable.
Understand, characterize, seam, small refactor, change behaviour, verify — in that order, because each step is what makes the next one safe rather than brave.
Put a routing layer in front of the old system, move one behaviour at a time behind it, expand until nothing is routed to the old system, then retire it. The old and new run together for a long time.
Rewrites fail for four specific reasons — hidden requirements, the delivery gap, migration complexity and feature-freeze pressure. They are sometimes still the right call, and the conditions are nameable.
Old and new coexist; you migrate one slice, verify it against reality, and repeat. The design work is choosing the slice and defining what "verified" means.
Migration & Compatibility
6 lessonsEvery change to a running system has an initial state, a transition state, a final state and a way back. Backward compatibility, versioning and data migration.
Every change to a running system has an initial state, a transition state, a final state and a way back. The transition state is the one people skip, and it is live in production longest.
Old clients, stored data, in-flight events, published APIs, plugins and databases all constrain what today's change may do. Compatibility is not a property of an API; it is a constraint on every edit.
An explicit version lets old and new consumers disagree about the contract. It also creates a maintenance obligation that lasts as long as the oldest version you have not managed to kill.
Schema, data and application code are three things that must change in a safe order. Getting the order wrong is the difference between a routine deploy and a restore from backup.
Add the new shape, write both, migrate readers, stop writing the old, remove it. The canonical safe sequence, and the reason it works is that every step is individually revertible.
A flag decouples deploying code from releasing behaviour, which is genuinely valuable. It also multiplies the state space of the system and creates a cleanup obligation nobody is measured on.
Evolvability
7 lessonsChange amplification, local reasoning and encapsulation radius — the properties that decide whether the tenth change is as cheap as the first.
A system is evolvable when the changes it is actually likely to receive stay local and understandable. The word "likely" carries the entire claim.
One requirement changes; count the modules, interfaces, tests and deployments that must move with it. Lower is usually better, and not always.
Pick an implementation decision and ask who has to be told when it changes. The set of modules that notice is the radius, and a smaller one is stronger hiding.
Extensibility is cheap along one axis and expensive along every other, so the only real question is which variation you have actually observed.
A plugin system is right when independent parties genuinely must extend you. What it costs — a frozen API, a lifecycle, isolation and compatibility — is permanent.
Something many modules depend on is expensive to change. Point dependencies toward the things that change least — and treat every stability metric as a hint, never a measurement.
Machinery built for a variation that never arrived: one implementation behind an interface, a plugin system with no plugins, an event bus for a local call.
Module & Package Structure
8 lessonsGrouping by feature or by layer, what each does to change locality, and why cycles are a reasoning problem before they are a build problem.
A package is a claim about what changes together. Grouping by domain, feature or capability makes that claim; grouping only by technical type makes no claim at all.
controllers / services / repositories. It is genuinely good at cross-cutting technical change and at being guessable, and it scatters every feature across every folder.
orders / payments / users, each with its own layers inside. Requirement-shaped change becomes local, and shared concepts lose their obvious home.
One folder per use case, containing everything that use case needs. Change locality is close to maximal, and shared concepts have nowhere obvious to live.
A depends on B depends on C depends on A. What breaks is reasoning first, then initialisation order, then testability, and only last the build.
Move the shared concept, invert a dependency, introduce an interface — or merge two modules that were never really separate. The last one is the most under-used fix.
Depend toward the things that change less often than you do. It is a statement about rates of change, not about which folder sits lower in a diagram.
How big should a module be? Big enough that a likely change fits inside it, small enough that one person can hold it. "As small as possible" is not an answer to either question.
Architecture Boundaries
6 lessonsLayered, hexagonal, clean and onion compared honestly — plus adapters and anti-corruption layers, which are the part that survives whichever style you pick.
Domain, application, infrastructure and transport is a useful model of where the seams fall inside a codebase. It is one model, not universal truth, and saying so is the lesson.
An application core that declares the interfaces it needs, and adapters on the outside that satisfy them. An ordinary dependency-direction choice, worth its cost exactly where the outside varies.
Policy inward, details outward. One approach among several — and the one most often adopted whole, at a ceremony cost nobody prices before committing.
Concentric rings with the domain model at the centre. Nearly the same idea as hexagonal and Clean, drawn differently — and saying so is more useful than pretending they are three schools.
Translate external formats into your own model at the edge, once. The rule that keeps a vendor SDK's types from spreading through code that has nothing to do with the vendor.
When the other system's concepts are wrong for you — not just its formats — translate the model, not the fields. The layer exists so their vocabulary cannot colonise yours.
Monolith & Modular Monolith
5 lessonsA monolith can be well designed, and usually should be tried first. Internal module contracts, shared libraries, and the `common/` folder as a design failure.
A monolith is a deployment decision, not a structural one. It can be well designed, it usually should be where a team starts, and conflating it with "big ball of mud" costs teams years.
One deployment, several modules, each owning its domain logic, its interface and its data. The internal-boundary discipline of services without the operational bill.
Inside one deployment, the contract that matters is who may touch which data. Arbitrary cross-module table access is what makes a modular monolith aspirational rather than real.
The internal package everyone depends on. Its fan-in is the point and also the problem: every change ripples outward to code you did not open.
`common/`, `shared/`, `utils/` — where code goes when nobody will own it. The cause is missing ownership, not laziness, and the fix has to address the cause.
Feature Design
5 lessonsThe work that happens before the first line: goal, rules, state changes, interfaces, persistence, errors, observability, tests — and what fails if you skip it.
Eight questions stand between a ticket and the first line of code. Skipping one does not remove the decision — it relocates it to whichever branch of the code happens to run first.
Twelve fields, filled in for a real feature. The value is not the document — it is that a blank field is visible in a way an unasked question is not.
Four questions that change the structure rather than adding a branch: what if the database is gone, the dependency is slow, the request arrives twice, and half the work already succeeded.
Error, repeat and partial-failure behaviour decided first, because a structure built around the success case has no room left for them — and that is where the mess comes from.
A slice is a thin path through everything that delivers something observable. A layer is a horizontal band that delivers nothing, and calling it a slice is how all the risk ends up in the last week.
Debuggability by Design
6 lessonsA system that can answer what happened, why, for whom, on which version. Stable ids, deterministic cores, and time and randomness as injected dependencies.
A system should be able to answer what happened, why, for which request and user, on which version, and from which state. None of those are answerable later if the design did not record them.
Log state transitions and external interactions. A log line is an interface with a future reader, and most debug logging is a message the author sent to themselves an hour ago.
request_id, order_id, workflow_id. Correlation is a design decision made in the first week or not at all, because an id cannot be added to records that were written without it.
Same inputs, same outputs, every time. A domain core with no ambient time, randomness, I/O or global state can be tested exhaustively, replayed from a log, and reasoned about without running it.
A `now()` buried in a rule makes the rule untestable and unreproducible. Injecting a clock fixes that and costs a parameter threaded through code that did not want one — which is a real price, not a rounding error.
The same argument as the clock, applied to anything that returns a different answer each call: id generation, shuffling, sampling, jitter. Injected, they are reproducible; ambient, they are a bug you cannot re-run.
Testing as Design Feedback
7 lessonsHard-to-test code is usually telling you something about its dependencies. Where to put the boundary, what to double, and when mocks start mirroring implementation.
Code that is miserable to test is usually hiding a dependency or mixing two jobs. That is real information — and it is not a licence to bend the production design around a test runner.
A "unit" is not a class and not a method. It is a boundary you have chosen to hold stable — which makes choosing it a design decision, not a testing convention.
Some abstractions are load-bearing precisely because the thing underneath them is complicated. Replacing those with a double tests your belief about the dependency rather than the dependency.
Mock at boundaries you have chosen to keep stable. Mock every internal collaboration and the suite becomes a cast of the implementation — and then it argues against the refactoring it was supposed to enable.
Stub, fake, mock, spy and dummy are not synonyms. They differ in what they know and therefore in how they fail — and picking the wrong one is how a suite ends up brittle or blind.
A contract test is the thing that keeps a double honest. It is also a design decision: writing one is a declaration that this seam is a contract and not an implementation detail.
When a behaviour can be stated as something true of every input, you can test the statement instead of a handful of examples — and being unable to state one is itself a finding about the design.
Designing for Failure
7 lessonsWhat a design owes once calls can time out, repeat or half-succeed — including idempotency as a property chosen up front rather than retrofitted.
A function call either returns or throws. A remote call has a third outcome — you do not know — and an interface designed without a name for it will be wrong in a way no amount of error handling fixes.
Idempotency is a property of a signature, not a feature you add later. `createPayment(commandId, amount)` has the id in it because the failure model put it there — and no discipline around `createPayment(amount)` can substitute.
A local operation is all-or-nothing because the language and the transaction say so. A distributed one is not, and an interface that returns one boolean for five sub-operations is lying about what happened.
Retry safety is not something a caller can decide. It is a fact about the operation, and it has to be stated in the interface — otherwise every caller is guessing, and some of them will guess wrong.
Every piece of shared mutable state is a permanent tax on reasoning. Before reaching for a lock, ask whether ownership can be local, the data immutable, or the operation atomic — those remove the problem instead of managing it.
Whether a type may be used concurrently is part of its interface. Leaving it unsaid does not make it safe — it makes every caller guess, and the guesses are wrong at different times.
A function call becomes a message that may be lost, delayed, duplicated or half-processed. "We can split it later" underestimates this, because the cost is not the transport — it is every interface that was designed as if calls always return.
Designing for Security
5 lessonsTrust boundaries, least privilege and capability-passing as design decisions in the code, distinct from the attacker techniques Security Engineering teaches.
Five questions — what is trusted, what is untrusted, who may call this, what data is sensitive, where privilege changes — asked before implementation, because afterwards they are structural changes.
A trust boundary is a line in your code, not a line on a network diagram: the place past which data is assumed clean, which is only true if one place made it so.
Least privilege is usually taught as an infrastructure setting. At code granularity it is a parameter type: a function handed a reader cannot write, and that is enforced rather than reviewed.
Instead of handing a module a service container and hoping, hand it the specific things it may do: CanSendEmail, CanChargePayment. Powerful, honest, and more ergonomic cost than most codebases will accept.
Which data is sensitive, where it is allowed to travel, and why a type beats a convention: a value that cannot be stringified cannot be logged by accident.
Designing for Cost
5 lessonsInterfaces that hide what they cost — the repository call that loads a million rows, the N+1 that is an API-shape failure before it is a query failure.
Do not optimise blindly — and notice that the structural choices deciding allocations, copies, round trips and contention are all made before there is anything to profile.
An interface that hides what it costs is a design failure. `findAll()` looks like a getter and may read the whole table; a signature that cannot express a bound cannot be used safely.
The classic N+1 is treated as a query bug and fixed with an eager-load hint. Often it is an interface that only knows how to answer about one thing at a time, called from a loop that had no alternative.
Immutability, boundary adapters and DTO mapping layers all buy reasoning guarantees with copies. That is usually a good trade and it is never a free one, so the design should be able to say what it bought.
The quote is about small efficiencies and it is routinely used to dismiss all performance thinking. Structural cost decisions are not premature; micro-tuning without measurement is.
Complexity
7 lessonsEssential versus accidental, simple versus easy, and the maxims — DRY, YAGNI, KISS — restated so they say something falsifiable.
Some difficulty is the business rule itself and cannot be deleted, only moved somewhere honest. The rest is your encoding of it — and that part is negotiable.
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.
Every feature spends concepts, states, dependencies and failure modes out of a budget nobody is tracking. Tracking it does not make features cheaper — it makes the price visible while the decision is still open.
Do not build features or flexibility for a requirement nobody has. The rule is right often enough to be a default, and its cost is real: the refactor you deferred arrives under deadline pressure.
The simplest design that satisfies the known requirements — which is a different thing from the smallest amount of code you can write by ignoring some of them.
The rule is about a piece of knowledge having one authoritative home. It is routinely remembered as a rule about text, and that misreading produces shared abstractions that couple things which have nothing to do with each other.
Two opposite failures with the same cause — structure chosen without reference to expected change. The symptoms are recognisable, and the right amount is a function of how complex the domain is and how often it moves.
Engineering Decisions
8 lessonsTrade-off matrices, decision records with revisit triggers, refactor versus rewrite, build versus buy, and what a framework charges for what it gives.
Six axes that real design decisions move, scored side by side — useful because it forces every option to be described on the axes it is worst at, and dangerous because the digits look like evidence.
Context, options, decision, why, consequences, revisit trigger. Short enough to write in fifteen minutes, and worth writing because in two years the code will not say what was known when it was chosen.
A decision that was right becomes wrong when its assumptions change, and nobody is watching the assumptions. Naming the evidence that would flip the answer is the cheapest engineering practice there is, and the least used.
Five questions decide it: can the behaviour be characterized, can the change be incremental, is the architecture salvageable, how much undocumented domain knowledge is in there, and can old and new run side by side.
Four options rather than two, sorted by how much of the thing you end up owning. The question that decides it is whether this capability is what your product is judged on.
A library is something your code calls. A framework is something that calls your code. That inversion — not size, not scope — is what decides how much of your design it gets to make.
Productivity, conventions and an ecosystem, in exchange for its lifecycle, its architecture, an upgrade obligation and the moments its abstractions leak. All four charges are payable later, which is why only the benefits are visible on day one.
Sort decisions by what it costs to undo them, then spend evidence, meetings and caution in proportion. Most decisions are cheap to reverse and are treated as though they were not; a few are permanent and get decided in a chat thread.
Dependency Management
6 lessonsDirect and transitive dependencies, update risk, API stability, versioning as a communication convention, and deprecation as a lifecycle rather than a deletion.
Every package in the lockfile is code you now operate without having written it — and most of it arrived without anyone making a decision.
A small package can carry a large graph you did not choose, cannot audit, and run in production with your own privileges.
Four questions decide it: how much it does for you, how hard it would be to replace, what it drags in, and who maintains it.
An interface with no external users still has consumers. Changing it frequently across many of them costs coordination, and that cost is invisible in every individual diff.
A communication convention, not a guarantee. The number is a claim made by a human about their own code, and humans get it wrong in both directions.
Introduce the new thing, migrate the consumers, warn on the old, then remove it. Skipping a step does not save time; it moves the cost onto whoever is unlucky.
Repository & Team Structure
7 lessonsHow a repository is laid out, monorepo against polyrepo, ownership without silos, and the design reviews and RFCs that stop decisions living in one head.
A folder tree is a navigation aid and a change-locality claim. It should reflect how engineers actually move through the system, not a template someone copied.
Atomic changes, tooling, ownership, build scale, access control and release independence — six axes that pull in different directions. There is no universal answer, and anyone who gives you one is describing their last job.
Somebody has to be accountable for each part of the system, and the mechanism that achieves that is the same mechanism that creates silos. The tension is real; pretending it is not is how both failures happen.
Critical knowledge living in one head is a design risk, not an HR risk. It shows up in the estimate for every change to that area, long before anyone leaves.
Code review, documentation, pairing, design review and postmortems each spread a different kind of knowledge at a different cost. Choosing between them starts with naming which kind is missing.
Before a high-impact change, discuss requirements, options, trade-offs, migration, failure modes, security and observability — because this is the last point at which a boundary can still be moved.
Problem, Goals, Non-Goals, Design, Alternatives, Risks, Rollout, Open Questions. A written proposal for decisions too large for a meeting — and a reliable source of theatre when applied to decisions that are not.
Designing Agentic Systems
5 lessonsTreating a model as an external dependency that is non-deterministic, fallible and costly — and keeping the invariants in code the moment they have to hold.
The design question is not how to prompt. It is which decisions you are delegating to a component that will answer differently tomorrow, and which ones you are keeping in code.
Non-deterministic, fallible, mutable under you, priced per call and slow. Four of those five you already know how to design around; the fifth is the only genuinely new thing.
A rule that exists only in prompt text is hard to test, hard to enforce, hard to audit, and changes silently when someone edits a sentence or upgrades a model.
An API whose caller will not read the documentation carefully, will pass malformed arguments, and will invent plausible parameters that do not exist. Design for that caller.
Four boundaries — workflow, permission, output and fallback — and the argument that each one must be a thing in the code rather than an understanding in someone's head.