Constraints Are Part of 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.
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.
The textbook answer does not fit my team, my deadline or my existing system. Which one of us is wrong?
"Add multi-currency pricing." The obvious design is a pricing service with its own store and a money type used everywhere. The team is four people, the date is eight weeks away, prices live in a shared Postgres database that two other teams write to, and everything is on one cloud with no budget for a new managed component.
Design it properly and treat the constraints as problems to be escalated. If the right answer needs a service and twelve weeks, say so; engineering standards should not bend to a date.
The escalation succeeds and you get twelve weeks and a service — built by four people who now also own its deployment, its on-call and its schema, forever. The constraint was not removed, it was converted into an operational liability that arrives after the project is judged.
- The escalation succeeds and you get twelve weeks and a service — built by four people who now also own its deployment, its on-call and its schema, forever. The constraint was not removed, it was converted into an operational liability that arrives after the project is judged.
- The escalation fails, as it usually does, and the "proper" design is implemented at half scale under the original date. Half a service is worse than either whole option: a new boundary with none of the isolation that justified it.
- The design that ignored the shared table gets built, and then discovers at integration time that another team's job writes prices directly. Now the invariant is unenforceable and there are four weeks left (Invariant Leaks).
- Most damagingly: treating constraints as external makes them invisible in the design record, so in two years nobody knows why the odd-looking thing was odd, and it gets "cleaned up" into something that breaks the billing integration (Architecture Decision Records).
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.
- Four engineers, one of whom is on call every fourth week and one of whom joined last month.
- Eight weeks to a contractual launch date that a customer has been told.
- One Postgres instance, shared, with two other teams writing to the
pricestable. - A ten-year-old billing integration in a language nobody on the team writes, with no tests and one person who understands it.
- PCI scope: anything that touches card data enlarges the audit, and the audit is annual.
- A 400ms p99 checkout budget that is already 70% consumed.
- Displayed price and charged price are equal, in the same currency, at the same moment.
- Existing single-currency prices keep behaving exactly as they do today — a currency feature must not reprice anything.
- No new component enters PCI scope without the audit being re-planned, which cannot happen inside eight weeks.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The design owns satisfying the requirement *within* the constraints. A proposal that requires a constraint to be false is a proposal to change the constraint, and should be argued as that, separately.
- Someone must own each constraint's expiry: which of them are facts, which are decisions someone made, and which are simply nobody having asked.
- Whoever writes the design owns recording which constraints shaped it, because the shape is otherwise unexplainable later.
- Constraints move boundaries, and this is the concrete way they enter the design. A shared table means the boundary cannot be "the pricing module owns prices"; it has to be "the pricing module owns the currency *rule*, and the table stays shared" — a weaker boundary, chosen deliberately.
- A legacy integration nobody can safely change means the seam goes in front of it rather than through it: an adapter that translates, so the new code never learns the old code's shape (Anti-Corruption Layer).
- Team size decides boundary count more than any principle does. Four people cannot maintain six modules with real contracts between them, and a design that assumes they can is describing a different team (Module Granularity).
The constraints that actually bind, and what each one does to the design
Constraints are not interchangeable. Each one pushes the design in a specific direction, and knowing the direction is what turns "we have a deadline" into an engineering input rather than a complaint.
The rightmost column is the one worth arguing about, because it is where the constraint stops being a fact about the world and becomes a decision about the code.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Existing database, shared with other teams | You cannot make your module the only writer | Storage boundary and module boundary do not coincide | Keep the module boundary strict anyway and put the invariant in a database constraint, which is the one place all writers pass through (Where Invariants Live). |
| Four engineers | Every additional module has an owner who is also on call | Boundary count is bounded by people, not by principle | Fewer, larger modules with real internal contracts. Six well-named files beat six packages (Module Granularity). |
| A contractual date | The design has to be shippable in slices | Delivery shape is now a design property | Slice by requirement rather than by layer, so each slice is releasable (Vertical Slices). |
| Legacy integration nobody can change | Its data model leaks into every new feature | No seam between its vocabulary and yours | An adapter in front, translating both ways, and a rule that nothing else calls it (Anti-Corruption Layer). |
| Compliance scope | Some components cost an audit to touch | Regulatory boundary crossing a technical one | Design so the scope boundary is a module boundary, and keep the number of things inside it small (Trust Boundaries). |
| A consumed latency budget | No room for another synchronous call | The interface, not the implementation, decides what the caller waits for | Move work off the write path deliberately, and accept that something becomes eventually visible (Cost-Aware Interfaces). |
| A language or runtime you must use | The idiomatic design from elsewhere does not translate | What the language can enforce differs | Choose the enforcement mechanism the language actually has — a runtime guard and a test where there are no sum types (Making Illegal States Unrepresentable). |
The same feature under two constraint sets
This is the part that makes people uncomfortable, and it should. The two designs below are both correct, they are correct for different teams, and neither is a compromised version of the other.
The matrix scores what each option gives you. What it cannot express is the thing that actually decides: whether your four engineers can carry the operational surface of the second option in eighteen months, when two of them have left.
| Option | Simplicity | Flexibility | Performance | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|---|
| Pricing module, existing deployment, shared table | One deploy, one transaction, no network on the price path. Flexibility is genuinely low: another team can still write a price behind your back, and the only enforcement available is a database constraint. Correct at four engineers and eight weeks. | ||||||
| Pricing service, own store, API | Real isolation — nobody writes a price except through you — which is the only design that fully protects the invariant. It costs a network hop inside the checkout budget, a data migration out of the shared table, a second deployment and an on-call rota. Correct at thirty engineers, or at four if pricing is the product. | ||||||
| Currency as a column, rule inline at each call site | Fastest to the date and genuinely the right answer if this is the only currency requirement that will ever arrive. It is the design that loses if a second one comes, because the rule now has an unknown number of addresses (Change Amplification). |
caveat These scores compare designs, not outcomes, and they hide the two things that decide most real cases. First, ownership over time: a low mark in the operational column means nothing on the day it ships and a great deal when the team is different. Second, reversibility — the first option can become the second later at roughly the cost of the data migration, while the third cannot become either without finding every call site. A matrix that scored reversibility would rank these differently, and it is left out here deliberately so you notice it is missing.
Sorting constraints before designing against them
Most of the value in this lesson is in one twenty-minute exercise: take the list of things that make the obvious design impossible, and sort each into fact, decision or assumption.
It is worth doing out loud with someone who was there when the decisions were made, because the sorting is where the surprises are — the constraint everyone treats as a fact is often a decision from a team that no longer exists.
Who would have to agree for this constraint to stop being true, and what would it cost them?
when A launch date a customer was told; PCI scope; the speed of light across regions.
cost Design within it. Arguing costs schedule and changes nothing, and a design that requires it to be false is not a design.
when The shared database; the cloud provider; the language; the team's size this quarter.
cost Separate the argument from the design. Propose the change on its own merits, with its own cost, and meanwhile design as if the answer is no — because it usually is, and because a design contingent on an unresolved argument cannot start.
when "We cannot buy a managed service"; "the billing system cannot be changed"; "legal will not allow it".
cost Go and check, today, before designing. This is the highest-return hour in the whole exercise and it is skipped constantly, because checking risks being told yes and having more work.
when A rule dating from an incident four years ago, or a limit from a database version you no longer run.
cost Confirm and remove it, and write down what you confirmed. Half of what a long-lived team treats as constraint is this (Revisit Triggers).
when "The deadline" explaining an absence that predates the deadline.
cost Name it as debt with an owner rather than as a constraint, because a constraint expires on its own and debt does not (Deliberate Debt).
How to build it
Most important first.
- List the constraints before the options, not after. A list written after the options is a list of reasons the preferred option is fine.
- Sort them: hard facts (the launch date a customer was told), decisions (the shared database), and assumptions (that we cannot buy a managed service). Only the first kind is genuinely immovable, and teams routinely mis-sort the other two (Reversible and Irreversible Decisions).
- Design for the hard facts, argue about the decisions separately, and go and check the assumptions — half of them turn out to be nobody having asked in three years.
- Prefer the design that is smallest under the tightest constraint. With four engineers and eight weeks, that is a module inside the existing deployment with a real internal contract, not a service (The Modular Monolith).
- Record which constraint produced each unusual decision, in the code, next to the decision. This is the single highest-value paragraph in most design records (Decision Records).
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.
- Under the constrained design — a pricing module in the existing deployment, an adapter in front of billing, currency as a column on the existing table — adding a third currency costs one row of configuration and one test. Adding a *second consumer* of pricing costs one call. Neither requires the other teams to do anything.
- The constraint that will hurt is the shared table. When another team adds a column with its own semantics, or writes a price without a currency, the pricing module cannot stop them, and the cost of the next change includes finding out. That cost is bounded only by a database constraint, which is why the design puts one there (Enforcing Invariants).
- If the team grows to twelve and the shared database is split, the pricing module becomes extractable at roughly the cost of the data migration alone — because the module boundary was real even though the storage boundary was not. That is the specific bet this design makes, and it is the reason to keep the module boundary strict when the storage boundary cannot be.
- Under the "proper" service design delivered at half scale, every one of those changes costs a cross-repository change and a deployment ordering question, and the third currency is a two-week job because it touches two release trains (DevOps owns the deployment coupling; the design cost is that the change is no longer atomic).
- Designing within constraints means shipping something you would not choose in a vacuum, and being able to explain that to a reviewer who is comparing it against the vacuum.
- It also risks normalising the constraint. A team that always designs around the shared database will eventually stop noticing that splitting it is an option.
- And a constrained design is harder to justify in a written record, because the good reasons are contextual and the record outlives the context.
What can go wrong
- A constraint is treated as permanent when it was a decision made under different circumstances, and the design pays for it for a decade.
- A constraint is treated as negotiable when it is a legal or contractual fact, and the design is discovered to be unshippable during a compliance review.
- The constraints are recorded and the *reasoning* is not, so when the constraint lifts — the team grows to twelve — nobody knows which parts of the design were only there because of it (Revisit Triggers).
- Constraints are used as a general excuse. "We had a deadline" explains a specific shortcut; it does not explain an absence of tests eighteen months later (Accidental Debt).
- A constraint you accept becomes a dependency: designing around the shared
pricestable couples you to two other teams' write patterns, and that coupling should be written down rather than discovered (Shared-State Coupling). - Designing around a legacy integration through an adapter means depending on the adapter, not on the legacy system — which is the entire value of the adapter and is lost the moment one call bypasses it.
- Compliance scope is a dependency with a schedule: it changes annually and on its own timetable, which means designs that keep components out of scope keep their own release cadence.
- "Constraints are excuses." Some are. The distinguishing question is whether the constraint is checkable by someone else: "the launch date is contractual" is checkable; "we did not have time" is not (What Technical Debt Actually Is).
- "So we should always take the pragmatic option." No. Sorting constraints into facts, decisions and assumptions frequently reveals that the constraint blocking the better design is an assumption nobody has tested.
- "The right design is the same everywhere and constraints just delay it." This is the belief underneath most cargo-culted architecture. There is no context-free right design, which is why every recommendation in this domain carries a team size, a domain complexity and a lifetime (Design, Architecture and System Design).
- "Recording constraints is process overhead." It is two sentences, and it is the difference between a future engineer understanding the design and reverting it (Architecture Decision Records).
Testing it, and how it ages
- Test the currency rule with no database at all — it is the part that is genuinely yours (What a Unit Is).
- Test the adapter against recorded real responses from the legacy billing system, because its actual behaviour and its documented behaviour differ and only one of them is a constraint (Characterization Tests).
- Add a database constraint, then test that it fires. A constraint you have not seen reject a write is a comment (Backend Engineering covers the mechanics; here the point is that it is the only chokepoint every writer shares).
- Constraints expire, and the design should say when. "Shared database" ends when the other teams move; "four engineers" ends at the next hiring round; "PCI scope" is re-scoped annually.
- The design ages badly if it hard-codes a constraint rather than isolating it. A module that assumes one currency is worse than a module that takes a currency and is currently only called with one.
- The most common evolution is that the constraint lifts and nobody notices, so the design keeps paying for a restriction that no longer exists. That is what a revisit trigger is for.
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.
- GENERALThat a design must satisfy the requirement within the actual limits follows from what "design" means; what varies is which limits bind, and a constraint list from another company is worth nothing to yours.
- SCALE-SPECIFICAt four engineers, team size is usually the binding constraint and it argues for fewer boundaries; at four hundred, coordination cost binds and it argues for more, because the cost of two teams sharing a module exceeds the cost of a contract between them. Advice that ignores which one you are is the most common way engineering blog posts mislead.
- CONTESTEDThe strongest opposing view: constraints are usually softer than teams believe, and a discipline of designing within them teaches learned helplessness — the teams that escape a shared database or a legacy integration are the ones that refused to design around it one more time. That is right about a specific and important minority of constraints, and it is why the sorting step exists; it is wrong as a default, because most attempts to remove a constraint mid-project simply consume the schedule and then design around it anyway with less time.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — where the binding constraints are usually physical and financial rather than organisational: region latency, storage cost per terabyte, and how much redundancy the business will pay for.