Software Engineering & Design
How do I turn changing requirements into software that is understandable, testable, maintainable, evolvable and safe to change over time? Not a SOLID recital, not a pattern catalogue, not clean-code slogans.
Good software design controls the cost of change.
That is the whole claim, and it is deliberately falsifiable. Not elegance, not pattern count, not how it reads in a screenshot — the measurable thing a design decides is what the next requirement costs: how many modules move, how many tests move, whether anything has to be migrated, and whether one person can still reason about the result locally. A learner who finishes this hears “add one more feature” and does not reach for a pattern. They ask what changed, which invariant moved, who owns it, and what the change after this one now costs.
The order is the argument. The requirement and its constraints decide which invariants must hold; the invariants decide where responsibility for protecting them sits, and therefore where the boundaries fall; the boundaries decide what an interface has to say, and therefore what can be tested in isolation. Start in the middle — pick a pattern, draw layers, add a container — and you get a structure that is locally defensible and expensive to change, because nothing in it was derived from something that has to be true.
This is the codebase domain
Software Engineering & Design does not own every layer it touches. It owns the code a team edits every day — and deep-links its neighbours rather than re-teaching them.
Software Architecture decides how subsystems are bounded and in what style, and System Design decides how a whole distributed system meets its scale and reliability goals. Both hand down constraints this domain has to live inside — and both leave open the question of how the code *inside* a subsystem is structured so it can change.
The codebase a team edits daily: responsibilities, invariants, interfaces, state, dependencies, changeability. Which of our internal seams deserve to be a contract at all, who owns which data, what testability is telling us about the design, and what the next change costs. No other domain answers that last one.
API Design, Backend, Frontend, Database and Security Engineering each own a layer in full, and Testing & Reliability owns confidence and resilience. Lessons here teach the structural decision and hand the rest off by name.
Flagship experiences
The parts of this domain that are not reading.
Pick a requirement change and see which modules, tests and deployments move — under a god-object design and a bounded one, side by side. The model is honest: a good design does not make every change cheap, it makes the *anticipated* ones cheap.
"Add Pause Subscription" from requirement through domain rule, state machine, contract, application logic, persistence, events, tests, migration and observability. Every step has a "go one layer deeper".
Pick a unit; see what it knows, what it does, what it depends on, and every distinct reason it has to change. More than one or two reasons is the finding.
Build order, payment and deployment lifecycles — then the lab checks which transitions you left reachable that should have been impossible to express.
Messy code, a menu of moves, and the coupling consequence of each — including the moves that make it worse while looking like progress.
Change billing in a decade-old service with no tests: observe, characterize, find the seam, refactor, change, verify. Legacy means unprotected, not old.
States, rules, contract, persistence, errors, migration and tests defined *before* the first line of implementation — and what fails when each field is skipped.
A proposed design. Find the unclear ownership, the hidden state, the unsafe migration, the abstraction nobody needed and the failure nobody handled.
A decision tree, plus refactor versus rewrite. Every leaf names why, what it costs, the simpler thing to try first, and how the recommended design itself fails.
Design a SaaS billing system, then absorb change requests one at a time — and modernize a system whose behaviour nobody can fully state.
Four levels, with the cause unlabelled. Each one carries the trap: the fix that looks right, passes review, and leaves the real design problem in place.
What each question is really testing, what a strong answer sounds like, and the red flags that separate a recited acronym from a working model.
Learning modules
Thirty-eight modules, from what actually makes software hard to change to designing systems whose most unpredictable dependency is a model.
What 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.
Design starts from what must be true, not from choosing a pattern. Functional and non-functional requirements, and the constraints a valid design cannot ignore.
The things that must never stop being true, and the engineering question that follows: which layer is actually responsible for protecting each one.
Splitting 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".
An abstraction hides detail behind a useful model, and charges indirection, vocabulary and leakage for it. When to pay, and when duplication is cheaper.
Keeping implementation behind stable boundaries, and the sharper idea underneath it: hide the decisions most likely to change.
The 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.
Direction, 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.
Each principle by the problem it addresses, a real example, a real misuse and a counterexample. Useful heuristics, not laws, and the difference matters.
Why composition avoids rigid hierarchies, when inheritance genuinely earns its place, and polymorphism as a response to variation that is actually there.
Each 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.
Entities, 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.
Making lifecycle explicit, so that invalid transitions become impossible to express rather than merely undesirable — and boolean-flag explosion becomes visible.
Failure as part of the design: separating expected business failure from validation, dependency failure and outright bugs, and choosing how each is expressed in types.
Pure computation versus I/O and mutation, the functional-core / imperative-shell split, and what immutability buys and costs.
Names are the API you present to every future reader. Units, roles, side effects — and function design judged by responsibility rather than line count.
Comments that explain why, decision records that survive the author, and the failure mode nobody plans for: documentation that has quietly become wrong.
What review is actually for once tooling handles formatting, what a useful checklist asks, and why review size predicts defect detection better than reviewer skill.
Changing internal structure without changing observable behaviour — as a disciplined loop with a safety net, not a rewrite that someone called a refactor.
Heuristics 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 choice that raises the cost of future change. Deliberate and bounded is a strategy; accidental and unnamed is the thing that compounds.
Code 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.
Every 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.
Change amplification, local reasoning and encapsulation radius — the properties that decide whether the tenth change is as cheap as the first.
Grouping by feature or by layer, what each does to change locality, and why cycles are a reasoning problem before they are a build problem.
Layered, hexagonal, clean and onion compared honestly — plus adapters and anti-corruption layers, which are the part that survives whichever style you pick.
A monolith can be well designed, and usually should be tried first. Internal module contracts, shared libraries, and the `common/` folder as a design failure.
The work that happens before the first line: goal, rules, state changes, interfaces, persistence, errors, observability, tests — and what fails if you skip it.
A system that can answer what happened, why, for whom, on which version. Stable ids, deterministic cores, and time and randomness as injected dependencies.
Hard-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.
What a design owes once calls can time out, repeat or half-succeed — including idempotency as a property chosen up front rather than retrofitted.
Trust boundaries, least privilege and capability-passing as design decisions in the code, distinct from the attacker techniques Security Engineering teaches.
Interfaces 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.
Essential versus accidental, simple versus easy, and the maxims — DRY, YAGNI, KISS — restated so they say something falsifiable.
Trade-off matrices, decision records with revisit triggers, refactor versus rewrite, build versus buy, and what a framework charges for what it gives.
Direct and transitive dependencies, update risk, API stability, versioning as a communication convention, and deprecation as a lifecycle rather than a deletion.
How a repository is laid out, monorepo against polyrepo, ownership without silos, and the design reviews and RFCs that stop decisions living in one head.
Treating 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.
Reference
For when you already know roughly what you are looking for.
Levels, each unlocking something you can actually build and defend.
The searchable index: what a design problem sounds like in a standup, and what to think when you hear it.
The pairs people genuinely confuse — coupling and cohesion, refactor and rewrite, debt and mess, abstraction and indirection — with the confusion named.
Don't delegate understanding
The point of the domain, stated plainly.
Use all of them. Every one is worth using, and rebuilding them by hand is not the lesson. But know what owns the state, what protects the invariant, what depends on what and in which direction, and how the system changes tomorrow — because when one of these abstractions leaks, and each leaks in a characteristic way, you want to be debugging a system you have a model of rather than one you have been trusting.