No line in the requirements, nothing to argue about - the design argument is not a matter of taste.
๐ Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series has been about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs. This is the last part of it, and it goes back to the first link - because the requirement is where the shape of the system got decided, whether or not anybody wrote that line down. Running notes live on my GitHub: github.com/brilliant-almazov.
This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.
Primer, in one paragraph
A non-functional requirement says nothing about what the system does and everything about the conditions it does it under: load, latency, the ratio of reads to writes, how long data has to be kept, whether tenants share storage, what has to stay compatible with what. The systems here are Go services being carved out of a PHP monolith. Each one ships as a single image, declares the processes it runs in a manifest, and stores its data in PostgreSQL. Below, a layout means the package layout of a service: which directories exist and what is allowed to live in them.
The thesis
The arguments that eat the most calendar time in a design discussion - microservice or module, CQRS or not, a bus or a direct call - are not settled by taste, seniority or precedent. They are settled by a line in the non-functional requirements.
The useful half of that claim is the contrapositive: if there is no such line, there is nothing to argue about, because the requirement has not been gathered yet. A design discussion that cannot point at the line it is answering is not a design discussion. It is two people comparing preferences with a diagram between them.
The case: one line, all the way down to a directory name
The line: reads and writes behave differently here - different volumes, different shapes, different failure modes.
That is a sentence about conditions, not about features. Here is what it turns into, with nothing in between.
Exactly two layers touch the database. repository reads. manager writes. There is no third one - no store, no writer, no dao, no service. A manager that needs to read something calls a repository instead of growing its own SQL.
The layout runs from the component type, with the domain as a nested package - not the other way round:
internal/
repository/
entity/ reads only
order/
manager/
entity/ writes only
order/
handler/
grpc/
entity/
What it looks like in numbers, measured on 2026-08-16: 2 733 .go files excluding generated code, 252 packages, average file size 39 lines. That average is the tell. A layout that splits by component type first produces many small files by construction; you do not arrive at it by asking people to write shorter files.
What it cost. Two packages where most codebases have one thing called a service, and a manager that wants to read has to go through a repository instead of writing three lines of SQL where it stands. That is a real tax, paid on every feature, and it buys exactly one thing: the read path and the write path can be changed, measured and reasoned about separately - which is what the requirement line asked for.
Two neighbours of the same order, from the same set of requirements:
-
Heavy background work sits next to synchronous handles. โ two binaries out of one image,
serverandworker, declared in the service manifest. -
This data has to be kept, and old states have to be reconstructable. โ monthly audit partitions with retention done by dropping a partition,
HASHpartitioning by owner, and two time axes instead ofUPDATE. 66 migration files, 1 990 lines of migrations as of the same date.
And the boundary, said plainly: this is a breakdown of a structure and its price, not a report from a running installation. There is no production here with thousands of databases per tenant, and this service does not operate a shard map of its own.
How this is usually done
Three patterns, none of them stupid, all of them the same failure:
- Non-functional requirements as a trailing section. The vocabulary is not the problem - ISO/IEC 25010 and ISO/IEC/IEEE 29148 provide perfectly good catalogues of quality characteristics. The placement is: a section after the functional part, written once for the approval meeting, never opened again during design.
- The style is chosen by popularity and by what the team has done before. Which is a real input - Fowler's microservice trade-offs piece is largely about team-shaped costs - but it answers "what can we operate", not "what does this system need".
- "We will figure it out when we hit it." Sometimes correct. It becomes a problem only when the thing hit is a data-model decision, because those are the ones that do not move afterwards.
What the three have in common is that they leave the design question with no cited input. A question with no cited input gets answered by whoever is most confident in the room.
The five lines
| Requirement line | What follows from it |
|---|---|
| reads and writes carry different load and have different shapes | reads and writes are separate layers, and it is visible in the package layout |
| heavy background work sits next to synchronous handles | separate daemons of one service, declared in the manifest |
| any asynchronous processing | a queue, not a goroutine scanning a table |
| tenant isolation and data volume | a database per tenant, application-level sharding, or partitioning |
| history and corrections have to be reconstructable | a bitemporal model instead of UPDATE
|
The rest of this is one section per row.
1. Reads and writes are two layers
Covered above. The one thing worth repeating is the negative half: the rule is not "prefer small packages", it is that four common names do not exist. store, writer, dao and service all let read and write logic share an object, which is precisely what the requirement line ruled out. Banning the names is the cheap way to enforce the split, because a name is checkable and an intention is not.
This is not full CQRS. There is one model and one database; what is split is the code path, not the storage. The original framing is worth reading mostly for how narrow the useful version of it is.
2. Separate daemons, one image
Two binaries out of one image: server speaks gRPC; worker relays outgoing events, runs background calculations and drops expired audit partitions. Both are declared in the service manifest - which means the set of daemons is a stated fact about the service, not a consequence of somebody having added a goroutine to main.
The manifest matters more than the count. A daemon that is declared has a name, a place in the deployment and an owner; a background loop started inside a process has none of the three, and shows up in an incident as unexplained work.
Who gets to add a third one is a separate question, and the answer is the service owner. That is deliberately not a technical decision.
3. A queue, not a goroutine
Any asynchronous processing goes through a queue. Not "usually", not "unless it is small": a goroutine that scans a table on a timer and does the work itself is not a form of asynchronous processing. It is a background job pretending to be one.
requirement: any asynchronous processing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
accepted producer โโโถ queue โโโถ consumer
back pressure ยท retry policy ยท visible depth ยท
the work survives a restart
not a form goroutine โโโถ SELECT ... WHERE pending โโโถ do it
none of the four, and no signal that none of the
four are there
The difference is not style. A queue gives back pressure, a retry policy, a visible depth and a place for the work to survive a restart; an in-process scanner gives none of the four, and gives no signal that it gives none of the four. Stating it as a rule rather than a preference matters because the scanner is always the cheaper thing to write on the day you write it.
4. One requirement, three different answers
Tenant isolation and data volume is the one line that does not resolve to a single structure. It has three answers, and they are not interchangeable:
- A database per tenant. The strongest isolation, and the operational cost lands on migrations and connection pools.
- Application-level sharding. Logical shards, a map from logical shard to physical address, and the shard bits carried inside the identifier itself, so the routing decision travels with the row.
-
Partitioning at the database level.
HASHby owner for the large tables, monthly partitions for the audit trail, and retention performed by dropping a partition rather than deleting rows.
Partitions are not shards. Partitions live inside one database and are transparent to the planner - the query does not know they are there. Shards are different databases and are not transparent: something in the application has to decide which one to open. The two get discussed as though they were points on one scale, and they are not on the same scale at all. (The PostgreSQL manual is unusually clear about which problems partitioning does and does not solve.)
The honest boundary again, because this is the row where it matters most: partitioning is what actually runs here. The database-per-tenant answer and the sharding answer are described as structures and prices, not as installations I operate.
5. Two time axes
We have to be able to show what we thought at the time is a requirement line, and it is the only thing that justifies the structure it forces.
Two time axes: the valid axis, when a fact was true in the world, and the system axis, when the system came to believe it. The current state is a slice through those axes, not a separate table. There is no destructive UPDATE in the domain tables and no physical DELETE; a correction is a new row that closes the previous one on the system axis.
The price is real and it is paid on every read: nothing is a plain SELECT any more, everything carries a slice condition. It is worth paying because of that one requirement line, and for no other reason. Without "show me what we thought at the time", the second axis is a tax with no payer - which is the standard reason to keep the bitemporal machinery out of a system that never asked for it.
Why it has to come from the requirement
For each of the five, ask what changes if the requirement line does not exist.
The structure does not change. The layers are still there, the queue is still there, the second time axis is still there. What disappears is the reason - and with the reason gone, the first person to question the structure is arguing about preferences, and so is everyone answering them. Six months later the split gets undone by somebody with an equally good taste-level argument for undoing it.
| If this line is missing | What is still true | What stops being true |
|---|---|---|
| reads and writes differ | the two layers still exist | why a manager may not hold SQL |
| heavy background work |
worker still runs |
why it is not a goroutine in server
|
| any asynchronous processing | the queue still delivers | why the scanner was refused |
| tenant isolation and volume | the partitions still drop | why the owner is the partition key |
| history and corrections | the second axis still fills | why every read carries a slice condition |
Every row of that table is an argument somebody will have. With the line, the argument takes a minute and ends in the requirements document. Without it, the argument is about experience, and experience is not a thing two people can check against each other.
That is the whole function of the requirement line. It is not documentation of a decision. It is the place where the decision was made, so that later arguments have somewhere to land other than seniority.
What it costs
- A requirement stated as a number is a commitment. Somebody will measure against it, and the measurement can come back negative. A vague requirement never fails an audit, which is exactly why vague requirements are so popular.
- A structure derived from a requirement is expensive to change when the requirement changes. That is the same cost curve as Part 1 of this series: the further down the chain a change lands, the more artefacts have to agree with it.
- Some of these answers are structure-and-price, not experience. Database-per-tenant and application-level sharding are in that category here. Presenting them as operational track record would be a lie, and would make the parts I do run less believable.
When not to do this
- The requirement is unknown and cannot be gathered. Then the honest activity is investigation, not design. A topology derived from a guess is a structure with a fake justification, which is worse than one with none.
- The load is knowably small. Every one of the five costs something. At small scale four of them are pure overhead, and the fifth is usually a lookup table.
- A prototype that lives until the first measurement. Its job is to produce the requirement line, not to obey one.
The multiplier line
An automated executor is very good at building whatever structure it is handed, and completely indifferent to whether that structure has a reason behind it. Hand it "split reads and writes" and it produces the layers; hand it "reads and writes carry different load, which is why they are split" and it produces the same layers - the code is identical either way. The difference shows up months later, in the first argument about whether to keep them, and by then the executor is not in the room. Speed of production multiplies the definitions it was given; the requirement line is where those definitions live.
From requirement to contract โ Part 7, and the last part of this block. The next block starts from the other end: a catalogue of services, and the rule that a service exists when it is declared - not when its code happens to run.
If you do this better, tell me where your non-functional requirements live and who opens them during a design argument. If you have been through this, which structure in your system turned out to have no requirement behind it? If you see it differently, say where deriving the topology from stated requirements cost you more than deciding it by experience. How is it solved on your side, and what broke there?





Top comments (0)