DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Requirements Come Before the Contract

Until the schema is declared, changing your mind costs one paragraph.


๐Ÿ‘‹ 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 is about the order the work happens in: what has to exist before anyone writes a line of code, and what that order is actually worth. This part is about the first links of that chain โ€” a wish, and the requirements it becomes. 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.


The moment a requirement changes

Someone comes back and says the thing should behave slightly differently. Same sentence, one clause changed. What that costs depends entirely on one question: has the contract been declared yet?

Before it has, the requirement is the only artefact that exists. Editing it is editing one paragraph. Nothing downstream has been generated from it, so nothing downstream has to agree with the edit.

After it has, the same clause moves five things that must stay consistent with each other:

  before                          after
  โ”€โ”€โ”€โ”€โ”€โ”€                          โ”€โ”€โ”€โ”€โ”€
  the requirement                 the contract
                                  generated code
                                  handwritten code
                                  tests
                                  documentation

  one artefact                    five artefacts, in step
Enter fullscreen mode Exit fullscreen mode

I am not going to attach hours or money to that. I do not have those numbers, and putting invented ones next to a real argument is how a real argument stops being trusted. The shape is the point: one edit against a coordinated edit.

What this article is about is where that line sits, and why it is worth knowing before you cross it rather than after.

The case: a form nobody ever decided

The clearest thing I have on this is not a dramatic outage. It is a boring one, which is why it generalises.

Reading rows out of a database is a form. Open a result set, loop it, check the error, close it. Or read exactly one row. Or count. Or ask whether a row exists at all. Four shapes, all of them decided a hundred times a year by whoever is typing at the moment.

That form was never stated as a requirement, so every author decided it locally. An audit of one service tree on 2026-08-13 found 44 copies of four forms:

form what it is for copies
many-row loop read a set of rows 18
single row read exactly one row 6
counter COUNT(*) into a number 8
existence probe does a row exist at all 12
total 44

Four bars โ€” many-row loop 18 copies, single row 6, counter 8, existence probe 12, 44 in total โ€” with the closing criterion and the package-count figures below them

Two details make this a requirements story rather than a tidiness story.

First, 12 of the 44 differed from each other only in the text of the error wrapper. Not in behaviour, not in the type they returned in any meaningful way โ€” in one sentence of prose attached to a failure. Twelve authors wrote the same loop and phrased the same failure twelve ways, because there was nothing to conform to.

Second, the right form already existed in that tree. It had been written, it worked โ€” and it lived inside a domain package, where nothing outside that domain could reach it. So the tree did not lack a solution. It lacked a statement that this was the solution, made before the copies existed.

What it took to close

Moving the 44 onto one core took a set of 40 iterations, one file per iteration. That is the honest cost line: not a heroic weekend, a queue of small mechanical changes, each one boring on its own.

The part worth stealing is not the refactor. It is the closing criterion that came out of it, which is checkable by a machine:

  for rows.Next()   ยท   rows.Err()   ยท   rows.Close()   ยท   QueryRow(
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  appear in the tree in exactly one package โ€” held by a test that
  fails the build the moment a second copy shows up
Enter fullscreen mode Exit fullscreen mode

That sentence is what a requirement is supposed to look like. It is not "be consistent about database reads". It is a condition somebody can run, that answers yes or no, and that keeps answering after everyone involved has forgotten the conversation.

The same service carries a second decision of exactly this kind. It grows by number of packages, not by size of file: it went from 13 packages at an average file of 50 lines to 252 packages at an average of 39. Whether you like that shape is beside the point โ€” it was decided once, in front, so no author had to re-decide it and no two authors could decide it differently.

How this is normally done

There is a well-developed craft here, and I am not pretending otherwise.

  • A requirements document from a template โ€” the Volere specification template is the classic one, and ISO/IEC/IEEE 29148 is the standard that formalises the whole activity.
  • A dedicated analyst role, with its own body of knowledge โ€” that is what BABOK is for.
  • Requirements as a wiki section, living next to the design pages.
  • A call, then minutes โ€” decide by voice, write down what was decided.

All of these work. They work best where there are more roles than there are here: an analyst who owns the document, reviewers who read it, a cadence that keeps it current. Where the same person holds the wish, the requirement and the acceptance, a heavy template mostly produces a document nobody re-reads. What I kept from that craft is the part that survives being small: every step leaves a file, and no step is a conversation.

How I do it โ€” the chain

Seven links. Each one is an artefact somebody can open, not a thing somebody remembers.

  1. Wish โ€” one sentence, in the customer's own language. Not a design, not a ticket: the sentence they actually said.
  2. Requirements โ€” functional and non-functional, written down as a list.
  3. Contract โ€” a .proto file. This is the first artefact that code is generated from.
  4. Tracker tasks โ€” one per executable unit, each with an estimate and an acceptance criterion.
  5. Spec โ€” a written specification for whoever does the work, precise enough to be executed without asking questions.
  6. Code and tests.
  7. Acceptance โ€” a gate that passes or fails.

Seven boxes in a row โ€” wish, requirements, contract, tasks, spec, code plus tests, acceptance โ€” each labelled with the artefact it produces, with a vermilion rule after the contract marked no code before this point

The line that does the work: before link 3, no code exists. Not "we try not to start early" โ€” there is nothing to start from, because the thing code is generated from has not been written yet.

What "non-functional" means here

This is the half that gets skipped, so it is worth being concrete. In practice the non-functional list is short and always the same headings:

heading the question it answers
load how much of this, and how often
latency how long the caller is willing to wait
read-to-write ratio which side of the system this feature actually stresses
retention how long the data has to stay readable
tenant isolation what one account is allowed to see of another
compatibility what already depends on this and must keep working

None of these is documentation for its own sake. Each one is a line that later decides something structural โ€” which layer takes the work, whether there is a queue in the middle, whether reads and writes are separate at all. That consequence is a whole topic of its own and it is the last part of this series; here I only want to establish that the line has to be written down before the contract, because the contract is where those consequences start to be expensive.

Decomposition goes by architecture, not by taste

Once a requirement is written, it has to land somewhere. Which layer it lands on is a property of the system, not a preference of whoever picked it up.

Three requirements on the left โ€” listing entities stays fast, an order is written in one transaction, recalculation never blocks the caller โ€” each with a straight arrow to the layer that owns it: read layer, write layer, background daemon

  • listing entities stays fast as an account grows โ€” a read requirement. It lands on the read layer: queries, indexes, pagination.
  • an order is written in one transaction โ€” a write requirement. It lands on the write layer: transaction boundary, conflict rules.
  • recalculation never blocks the caller โ€” a requirement about pace, not about either. It lands on a background worker: queue, retry policy.

The test of whether decomposition is really architectural is simple: hand the same sentence to two people and see whether it lands in the same place. If it can land in three places depending on who reads it, the sentence is not a requirement yet.

Why the order is worth keeping

Because reversing is allowed, and it stays cheap up to the fifth link.

Two panels โ€” before the contract, one box reading one paragraph; after the contract, five boxes reading the contract, generated code, handwritten code, tests, documentation, each marked as one of five that must follow

That gives one rule I can apply without thinking about it: a requirement that is still being argued about is reworked on the schema, not after generation. If two of us disagree about what a field means, the argument is cheap right now and expensive in an hour.

The second rule is about what a requirement is allowed to say. Every requirement needs a criterion that is either one command or one number.

Two panels โ€” works fast, marked as something that cannot be closed, against holds N requests per second below M latency, marked as something that closes the task

"Works fast" is not a criterion: nobody can say when it is met, so acceptance falls back to whoever is more insistent that day. "Holds N requests per second below M latency" is a criterion: one command runs it, or one number answers it, and both sides read the result the same way. I am deliberately leaving N and M as letters โ€” those numbers belong to the system being built, not to an article about the order of the work.

What it costs

This is not free, and pretending it is would be the fastest way to make it look like advice.

  • Somebody has to hold the whole chain. Seven links only work as one thread if one person can see all seven. That person is a bottleneck by construction.
  • There is no analyst function here, and no analyst. The owner of the task writes the requirements and runs the breakdown. That is fewer handoffs and also fewer second opinions โ€” a wrong requirement stays wrong until it is caught by acceptance.
  • There is no formal requirements document. No template, no numbered specification, no sign-off page. Requirements live in the tracker task and in the spec. It works because the artefacts are small and current; it would not survive a reader who joins in a year and expects a document.
  • On a small change the chain does not pay for itself. Seven links around a one-line fix is ceremony, and I skip it.

When not to do this

  • A research task, where the point is that nobody knows yet what should be built. There are no requirements to gather because the input to them does not exist. That work is investigation first, requirements after, and forcing the order the other way produces confident sentences about a system nobody understands.
  • A one-off change that will never be extended and has no consumers.
  • A task with no readiness criterion. If nothing can be checked at the end, acceptance is impossible and the last link of the chain is missing, which makes the other six decorative.

The multiplier

An automated executor does not care what order it got the facts in. Hand it a task with the form undecided and it will decide the form, confidently, and keep going โ€” and it will do that separately in each of the 44 places, because each place is a fresh conversation to it. It does not get slower when the input is incomplete; it gets more expensive, quietly, and the bill arrives later as inconsistency.

Which is the actual argument for the order. The chain is not there to help whoever is executing. It is there for whoever pays for the rework.


From requirement to contract โ€” Part 1. Next: there is exactly one contract, and it is a .proto file โ€” including for endpoints that are only ever spoken to over HTTP.

If you do this better, tell me where your requirements live and who keeps them current. If you have been through this, what did your version of the 44 copies turn out to be? If you see it differently, say where writing requirements first cost you more than it saved. How is it solved on your side, and what broke there?

Top comments (0)