The symptom says X → think Y
The searchable index of this domain. The left column is what a design problem sounds like in a standup, a retro or a ticket; the right column is what to think before you start typing — the mechanism, not the slogan.
72 of 72 rows
| The symptom says | Think |
|---|---|
| One small change touches twenty files | Shotgun surgery: a single piece of knowledge is written down in twenty places, so the requirement has twenty addresses. The fix is not "smaller files" — it is finding the knowledge and giving it one owner that the other nineteen call.Shotgun Surgery → |
| One class seems to do everything and nobody wants to open it | God object. Count its distinct reasons to change: if pricing, persistence, notification and formatting can each independently force an edit, four responsibilities are sharing one lock, and every change risks the other three.God Object → |
| The same business rule is copied in five places and one of them is already wrong | Duplicated knowledge, which is the thing DRY is actually about. Identical lines that encode the same rule must converge; identical lines that encode two rules which happen to agree today must not, because coupling them is how the next divergence becomes a bug.Duplicate Knowledge → |
| The object has isActive, isPending, isCancelled, isPaused and hasFailed | Boolean flag explosion. Five booleans admit thirty-two combinations and the domain has about six; the rest are states nobody designed and every reader has to rule out by hand. Replace them with one named state and explicit transitions.Boolean Flag Explosion → |
| I cannot test this without mocking six things | The mocks are the report, not the problem. Six collaborators means six hidden dependencies were reached for inside the unit instead of passed to it, so the test has to reconstruct the whole world before it can ask one question.Testing as Design Feedback → |
| Our utils folder keeps growing and nobody knows what is in it | It is not a module, it is the absence of one: things land there precisely because nobody decided who owns them. Each function in it belongs to some concept that has not been named yet, and naming the concept is the move.The Utility Dumping Ground → |
| The vendor SDK's objects are passed around the whole codebase | You have adopted someone else's model as your own. Wrap it at one boundary, translate into your types there, and the vendor's next breaking change becomes one file instead of a search-and-replace across the domain.Boundary Adapters → |
| One feature's logic is spread across controller, service, repository, mapper and DTO | Layer-shaped folders, feature-shaped changes. If every requirement crosses all five directories and stays inside none, the layout is organising by technical kind rather than by what changes together — a vertical slice puts the whole feature in one place.Vertical Slices → |
| Every service imports our common package | A shared dependency is a shared reason to change. Anything in there is coupled to everything that imports it, so a change to satisfy one caller ships to all of them — and the package accumulates unrelated things because it is the only place everyone can reach.Shared Libraries → |
| We need to replace this old system without a big-bang cutover | Strangler: put a routing seam in front of the old system, move one capability at a time behind it, and let old and new run side by side until the old one has nothing left to serve. Each step is independently revertible, which is the whole point.The Strangler Pattern → |
| Nobody knows what this old code is supposed to do | Characterization tests: write tests that assert what it currently does, including the parts that look like bugs, so that "I did not change behaviour" becomes an assertion rather than a hope. Correctness is a separate, later conversation.Characterization Tests → |
| This interface has twenty methods and most implementers throw on half of them | Interface segregation, read as a coupling statement rather than a rule: every caller depends on all twenty, so a change made for one of them recompiles and re-tests all of them. Split it along the lines the callers actually use.Interface Segregation, Critically → |
| This subclass compiles but breaks code that was written against the parent | A substitutability violation. The subtype strengthened a precondition, weakened a guarantee or added a state the callers do not check, so code that was correct against the base type is now silently wrong — inheritance made it invisible.Liskov Substitution, Critically → |
| Changing the database provider means editing domain classes | A leaked abstraction: the persistence model has become the domain model, so its concepts, its identifiers and its failure modes are visible in business rules. The boundary is where you invert the dependency, not where you add a folder called domain.Leaky Abstractions → |
| We might need to support other providers one day, so I added an interface | Verify the variation is real before paying for it. One implementation behind an interface is not flexibility, it is indirection with a second file to open — and interfaces extracted from a single case usually fit only that case, so the second provider forces a redesign anyway.Premature Abstraction → |
| Two modules import each other and the build order is a mystery | A dependency cycle. Cycles mean the pair can only be understood, tested, released and reasoned about as one unit, however many files it is spread across. Break it by moving the shared concept out or by inverting one of the two arrows.Breaking Cycles → |
| Everything depends on this module and it changes every week | A module with high incoming dependency should be stable; if it is both depended-on and volatile, every one of its edits propagates. Either stabilise its interface or push the volatile part out into something few things import.Stable Dependencies → |
| This method spends its whole body reading fields off another object | Feature envy: the behaviour is in the wrong class. It wants to live next to the data it keeps asking for, and moving it there usually lets several getters become private.Feature Envy → |
| This one file changes for four completely unrelated reasons | Divergent change — the mirror image of shotgun surgery. Four unrelated forces edit one unit, so every change carries the regression risk of the other three and merge conflicts are constant.Divergent Change → |
| You have to call init() before setUser() before save() or it blows up | Temporal coupling: the correct order is knowledge held by the caller and enforced nowhere. Either make the object impossible to construct in an invalid state, or make each step return the type the next step needs.Temporal Coupling → |
| Two features work fine alone and break when both are on | Shared mutable state that neither feature declared. Find who else can write the value, then decide who owns it — a piece of state with two writers and no owner is a bug waiting for a schedule.Shared-State Coupling → |
| This module has nine outgoing dependencies and does nothing itself | High fan-out concentrates other people's change into one place: nine upstream releases can each break it. That is sometimes exactly right for a composition root and almost never right for a domain rule.Fan-in and Fan-out → |
| These functions are in the same file but never call each other or share data | Low cohesion — they are filed together, not designed together. The file is a folder with a .ts extension, and a reader has to work out for each function whether it is part of the same thing.Cohesion → |
| The team split one class into eleven and it is now harder to follow | Over-decomposition. Each boundary you add costs a hop the reader has to make; if the pieces always change together, you paid for boundaries and got none of the isolation they exist to buy.Over-Decomposition → |
| Every field on this class has a getter and a setter | That is a struct with ceremony, not encapsulation. Encapsulation is about what the module decides on your behalf; if every decision can be overwritten from outside, no invariant can be maintained inside.Encapsulation → |
| Callers reach three levels down into our internal objects | The interface is wider than intended, so everything reachable is now part of the contract whether you documented it or not. What you exposed is what you must keep.Exposing Too Much → |
| Adding one field means editing seventeen constructors | The parameter list has become a data structure that nobody has named. A parameter object turns seventeen edits into one, and usually reveals that the group of values had a domain meaning all along.Introduce Parameter Object → |
| IDs, money and dates are all just strings and numbers | Primitive obsession: the type system knows nothing, so nothing stops a user id being passed where an order id belongs, or cents being added to euros. Wrapping them makes a whole category of bug a compile error.Primitive Obsession → |
| A twelve-branch switch on kind, repeated in four places | The branches are a type the code has not declared. Replacing the conditional with polymorphism means adding a new kind touches one new file rather than four existing ones — at the cost of the logic no longer being readable in one place.Replace Conditional With Polymorphism → |
| The algorithm needs to vary per customer tier | Strategy: name the varying step, give it an interface the caller selects at runtime, and keep the surrounding workflow fixed. Worth it once the variations are real and independent; premature if there is one tier and a guess.Strategy → |
| We use patterns everywhere and the code got harder to read | Patterns are a vocabulary for describing structure you already need, not a target shape. A factory producing a strategy consumed by a decorator is five files and one if-statement of behaviour if the variation was never real.Pattern Overuse → |
| A class reaches into a global registry to find what it needs | A service locator hides the dependency from the signature: you cannot see what this unit needs without reading its body, and a test cannot supply a substitute without global setup. Constructor injection makes the same dependency honest.Service Locator → |
| The domain layer imports the ORM | The arrow points the wrong way. Invert it: define the interface the domain needs in the domain, implement it in the infrastructure, and wire the two together at the edge where the application starts.Dependency Inversion → |
| Wiring the app together is spread across dozens of files | Composition should happen in one place, near the entry point, where the whole object graph is visible. Construction scattered through the code is why nobody can tell which implementation is actually running.Wiring and the Composition Root → |
| This abstraction has exactly one implementation and always will | Ask what variation it hides. If the answer is none, it is a rename with extra steps; if the answer is testability, consider whether the dependency should have been passed in rather than abstracted away.What an Abstraction Costs → |
| We built a plugin system and there is one plugin | Speculative generality. The extension point encodes a guess about what will vary, and guesses about variation are wrong more often than they are right — the second real plugin usually needs a hook you did not provide.Speculative Generality → |
| An order got refunded twice and we cannot reconstruct how | A transition that should not exist was reachable. Model the lifecycle explicitly, enumerate the forbidden transitions, and enforce them in the type or at the aggregate boundary rather than in whichever caller remembered.Invalid Transitions → |
| Three different modules update the same record | State with three writers has no owner. Pick the module that holds the invariant, make it the only writer, and give everyone else a method to ask for the change instead of making it.State Ownership → |
| Null keeps appearing in places we thought were safe | Absence is being represented by the same value as "not loaded yet" and "not applicable". Distinguish the cases in the type and the null checks stop being defensive habit and start being meaningful.Optional Values and Absence → |
| A catch block logs and continues | A swallowed error converts a failure into wrong data downstream, and the log line is read by nobody. Either handle it — meaning restore an invariant — or let it reach a boundary that can decide.Swallowed Errors → |
| Callers cannot tell which failures they are supposed to handle | The error taxonomy is missing: expected domain outcomes, programming faults and infrastructure failures are all arriving as the same type. Separate them and the handling decision becomes local and obvious.An Error Taxonomy That Survives Contact → |
| Every function returns a value or throws, and it is never clear which | Failures that are part of the domain deserve to be in the return type where the compiler can insist you deal with them; failures that mean a bug belong in exceptions. Mixing both conventions is what makes the codebase unpredictable.Result Types → |
| This function is pure except it also sends an email | A hidden effect makes the unit untestable and unreusable, and it means the caller cannot retry safely. Move the effect out to the shell and let the core return a description of what should happen.Functional Core, Imperative Shell → |
| The test passes alone and fails in the suite | Hidden global state — a module-level cache, a singleton, a static clock — carrying values between tests. If tests share state, so does production, which means order matters somewhere you never intended.Hidden Global State → |
| This behaviour is impossible to test because it depends on the current time | The clock is a dependency and it is not being passed in. Injecting it turns an untestable branch into an ordinary argument, and usually reveals two other places that were reading the wall clock too.Time as a Dependency → |
| The bug only reproduces in production, once a day | Something non-deterministic is inside the logic — time, randomness, ordering, ambient configuration. Push all of it to the edge so the core is replayable, and the intermittent bug becomes a failing unit test.A Deterministic Core → |
| People call this code legacy because it is old | Age is not the property that matters. Legacy means you cannot change it safely because its behaviour is unprotected — a well-tested ten-year-old module is not legacy, and an untested one shipped last month is.What "Legacy" Actually Means → |
| I cannot write a test for this without refactoring, and I cannot refactor without a test | Find a seam: a place where behaviour can be substituted without editing the logic — a parameter, an override, a link-time swap. The first change in legacy code is always the one that makes the second one testable.Seams → |
| We should just rewrite it | Ask what happens to the behaviour nobody wrote down. A rewrite restarts from a specification that does not exist, while the old system keeps shipping changes you must also implement — the migration, not the code, is what makes rewrites expensive.The Risk in a Rewrite → |
| The migration needs a moment where both schemas are live | Expand and contract: add the new shape, write to both, backfill, move reads, then remove the old one. Each step is deployable and revertible on its own, which is what makes the whole thing safe.Expand and Contract → |
| We have forty feature flags and nobody knows which are still needed | Every flag doubles the state space and the number of paths that must keep working. A flag without a removal date is permanent branching in the design, not a temporary switch.Feature Flags and What They Cost → |
| This is technical debt | Only if you can name the interest: what does the next change cost because of this, and how often does that change happen? Code you dislike with no recurring cost is a preference, and calling it debt makes the term useless for the cases that are real.Interest: Why Debt Compounds → |
| We took a shortcut to hit the date | That can be a legitimate trade if it is recorded — what was skipped, what it will cost, what triggers paying it back. Deliberate debt with a register is a financing decision; the same shortcut unrecorded is just a defect nobody will find.Deliberate Debt → |
| The old and new implementations must both work during the rollout | Design the coexistence explicitly: one owner of truth, an idempotent write path, and a way to compare outputs before switching. Most migration incidents happen in the overlap window, not at the cutover.Incremental Migration → |
| The estimate for every change keeps going up | Change amplification: the number of places a typical requirement touches is growing. Track which changes actually arrive and where they land — that history, not a diagram, tells you which boundaries are in the wrong place.Change Amplification → |
| We chose the framework and now our domain code is shaped like the framework | A framework calls you, which means its lifecycle, its types and its upgrade schedule become constraints on your design. That is often a fine trade, but it should be a decision with a stated cost, not a default.What a Framework Charges → |
| This design is elegant but everyone finds it hard to work with | Simple and easy are different properties: simple means few interleaved concerns, easy means familiar and near to hand. A design can be genuinely simple and unfamiliar, and the team cost of the second is real.Simple Is Not Easy → |
| Half the difficulty here feels self-inflicted | Separate the complexity the problem actually has from the complexity your tools, layers and history added. Only the second kind can be designed away, and mistaking the first for the second produces rewrites that land in the same place.Essential and Accidental Complexity → |
| Nobody can remember why we chose this | The decision was made and not recorded, so it now looks arbitrary and gets re-litigated every year. A record needs the alternatives, the constraints at the time, and what would make it wrong later.Decision Records → |
| This choice will be very expensive to undo | Sort decisions by reversibility, not by size. Cheap-to-reverse choices deserve speed; expensive ones deserve a prototype, a written trade-off, and an explicit trigger for revisiting them.Reversible and Irreversible Decisions → |
| Should we build this or use an off-the-shelf component? | The comparison is not build cost against licence cost. It is your ongoing maintenance against their upgrade cadence, their failure modes, and how central this capability is to what your product is for.Build, Library, SaaS or Managed Service → |
| We keep adding features and never removing anything | The complexity budget is being spent without being counted. Every option, flag and configuration point is permanent surface that all future changes must remain compatible with.The Complexity Budget → |
| Only one person understands this part of the system | A bus-factor problem is a design problem: knowledge lives in someone's head because the code does not explain itself and the boundaries do not match how work is assigned. Rotation and review help, but the structural fix is smaller, better-named surfaces.Bus Factor → |
| Reviews take days and the comments are all about formatting | The change is too large to review for design, so reviewers do what is possible in a big diff — spot surface issues. Smaller changes get better feedback about the things review is actually for.Review Size → |
| The tests break every time we refactor, even though behaviour did not change | The tests are coupled to structure rather than to behaviour, usually because they were written by mirroring the implementation with mocks. Move the assertion out to a boundary that the refactor does not move.What a Unit Is → |
| Two services agree on a contract in a wiki and disagree in production | A contract that is not executed is a hope. Contract tests make the expectation a build artefact that both sides run, which is the cheapest way to find out that one of you changed it.Contract Tests → |
| A retry made the problem worse | Retrying a non-idempotent operation duplicates it. Idempotency is a design property of the interface — a key, a natural identity, or a state check — not something the caller can add afterwards.Idempotency by Design → |
| This interface used to be a function call and now it crosses a network | Everything changes: latency is visible, partial failure is possible, arguments must serialise, and the caller now needs a timeout and a policy for uncertainty. An interface designed as in-process rarely survives the move unchanged.What Changes at the Network Boundary → |
| The list page issues one query per row | An N+1 is a design fact before it is a performance fact: the interface offers only a per-item accessor, so callers cannot express the batch they actually want. Fix the shape of the interface, not the call site.N+1 as a Design Problem → |
| The prompt has grown into a specification with business rules in it | Rules embedded in prose are untestable, unversioned and invisible to the type system. Keep the invariants in code, let the model do the part that genuinely needs judgement, and validate its output at the boundary.Business Logic Hiding in a Prompt → |
| Nothing in the logs tells us which request this was | Debuggability is designed in: stable identifiers that travel with the work, and logging placed at boundaries where a decision was made rather than sprinkled through the middle of the logic.Stable Identifiers → |
| Every module can read every table | Data ownership is the strongest boundary in the codebase and the one most often left implicit. If two modules can both write a row, they are one module with a shared secret.Internal Module Contracts → |