Skip to main content
WritingSpeakingCodeAboutNow

How to Write Specs, RFCs and ADRs

Spec driven development in practice: what an RFC, an ADR and a spec are each for, the templates I use, and one feature taken through all three from a blank page.

I wrote about spec driven development with LLMs back in April, and it argued that the spec is the highest-leverage thing an engineer writes. It did not show you how to write one. This is that article.

There are three documents, not one, and most of the confusion I see comes from teams using one of them to do another’s job. An RFC turned into a decision log. An ADR that is really a design. A spec that tries to record why the architecture is the way it is. Each document answers one question, and it only works if it sticks to that question.

DocumentAnswersCoversLives
RFCWhat should we build, and how should it work?A feature, a service, a change to a public contractFrozen once accepted
ADRWhat did we decide, and what did it cost?One decisionForever, until superseded
SpecWhat exactly does this piece of work do, and how do we know it is done?One piece of workUntil the work ships

I will use one feature as the example the whole way through, because the documents only make sense in relation to each other: an app that starts sending webhooks to its customers. If you read sending and receiving webhooks in Laravel, you have seen the code this produces. This is the paper that comes before it.

The order things happen in

The three documents are not alternatives. They come in a sequence, and the sequence is the process.

  1. An RFC proposes. Somebody writes down the problem, the design, the alternatives they rejected and what they are unsure about. It is a Draft, and people review it.
  2. The decisions inside it become ADRs. An RFC for webhooks contains several choices: how payloads are signed, how long delivery is retried, what happens to an endpoint that keeps failing. Each one worth finding again later gets its own ADR when the RFC is accepted.
  3. Specs slice the accepted RFC into work. “Record events”, “deliver them”, “let customers manage endpoints”. Each is a spec, small enough to build and review in one go.
  4. Code implements accepted documents. If building shows that a document was wrong, the document changes first, and then the code.

That last rule is the one that makes the rest worth doing. Without it the documents describe what you meant to build, and the code describes what you built, and within a month nobody trusts either.

Which ones a change needs

Not every change needs all three, and a process that demands an RFC for a typo will be ignored by Friday. This is the test I use:

  • A bug fix or a change inside decisions you have already made: none. A good commit message and a test.
  • A feature that fits the existing architecture: a spec.
  • A decision with no design around it, such as adopting a library, a naming convention or a rule about errors: an ADR on its own.
  • Anything that changes a public contract, crosses several parts of the system, or would be expensive to reverse: an RFC first, then its ADRs and specs.

Webhooks are the last kind. They are a public contract the day the first customer writes a receiver, and every decision in them (the headers, the signature, the retry schedule) is one you cannot change without breaking somebody’s integration.

Where they live

In the repository, next to the code, as Markdown:

docs/
├── README.md the process: this section, written down
├── rfcs/
│ ├── README.md index: number, title, status
│ ├── 0000-template.md
│ └── 0007-outbound-webhooks.md
├── decisions/
│ ├── README.md index: number, decision, status
│ ├── 0000-template.md
│ └── 0012-webhooks-are-signed-with-standard-webhooks.md
└── specs/
└── deliver-webhook-events.md

Not in a wiki. A wiki page is edited without review, drifts from the code without anyone noticing, and cannot be changed in the same pull request as the code it describes. In the repository, a change to a decision is a diff that somebody approves.

Number RFCs and ADRs with four digits, in the order they were written, and never reuse a number. Specs do not need numbers, because they are short-lived and named for the work. Each folder gets an index listing every document and its status, and the rule is that a status change updates the index in the same commit. An index that is out of date is worse than none, because it is wrong with confidence.

Writing the RFC

Here is the template:

# RFC NNNN: Title
- **Status:** Draft
- **Created:** YYYY-MM-DD
- **Depends on:** RFC NNNN, ADR NNNN (or none)
## Summary
## Problem
## Goals
## Non-goals
## Design
## Alternatives considered
## Decisions this records
## Open questions

Every section has a job. This is how I fill each one in, using the webhooks RFC.

Summary: write it last

One paragraph that tells someone who reads nothing else what you are proposing. You cannot write it until you know what the design is, so leave the heading empty and come back.

## Summary
The app sends customers a signed HTTP request when something happens in
their account: an invoice is paid, a subscription changes. Events are
recorded in the transaction that caused them and delivered by a queued job,
signed with the Standard Webhooks scheme and retried with backoff for about
three days. Customers register endpoints and rotate secrets through the API.

Problem: evidence, not adjectives

What is missing, for whom, and how you know. “Customers want webhooks” is a wish. This is a problem:

## Problem
Customers find out an invoice was paid by polling `GET /invoices` every
minute. Eleven of the forty API customers poll, and those eleven account
for 62 per cent of API traffic. Two have asked for webhooks in the last
quarter, and one prospect's security review lists them as required.

The numbers do two jobs. They tell the reviewer the problem is real, and they tell you later whether the solution worked.

Goals and non-goals

Goals are what has to be true when this is done. Keep them few and checkable:

## Goals
1. A customer learns of an event within a minute of it happening, without
polling.
2. A delivery that fails is retried for long enough to survive a deploy, an
outage or an expired certificate on the receiving side.
3. A receiver can verify that a request came from us and has not been
replayed.

Non-goals are the most useful section in the document, and the one people skip. They stop the reviewer asking for things you have decided not to do, and they stop the implementer building them anyway.

## Non-goals
- A dashboard showing delivery history. The data is recorded; the screen is
a later RFC.
- Filtering events by anything but type.
- Guaranteed ordering. Receivers get each event's timestamp and handle
order themselves.

Design: as much detail as building it needs

This is the bulk of the RFC, and it is the one section without a fixed shape, because it depends on what you are building. For webhooks it covers the payload, the headers, the signing scheme, the retry schedule, what disables an endpoint, and the API for managing endpoints. Show examples rather than describing them: a real request with real headers says more than three paragraphs about headers.

The test for “enough detail” is whether two people could build it from the RFC and end up with the same public behaviour. Internal structure can stay open. The contract cannot.

Alternatives considered: real ones, and why they lost

Every RFC has a section like this and most of them are padding: “We could also not do this.” An alternative only counts if a reasonable person would have chosen it.

## Alternatives considered
- **Our own signing headers** (`X-Signature`, HMAC over the body). Simpler
to describe, and it leaves the timestamp unsigned unless we design that
too. Standard Webhooks already did, and receivers may have its library.
- **A hosted webhook service.** Retries, a delivery log and a customer
portal on day one, at a per-message price that exceeds our margin on the
smallest plan.
- **Streaming events instead** (SSE or a websocket). No retries to build,
but every customer needs a long-lived connection, which is the polling
problem with better latency.

Writing the losing side well is what makes a reviewer trust the winning one. If you cannot make an alternative sound reasonable, you probably have not understood why someone would pick it.

Decisions this records

A list of the choices in the design that will become ADRs on acceptance. It is the bridge between the two documents:

## Decisions this records
1. Webhooks are signed with Standard Webhooks.
2. Delivery is retried ten times over about three days.
3. An endpoint that answers 410 is disabled immediately; one that fails
every attempt for three days is disabled and its owner emailed.
4. Event payloads are encoded once and stored as text.

Open questions: where the honesty goes

What you are not sure about, written down so review can settle it. An RFC with no open questions is either trivial or hiding something.

## Open questions
- Do we send thin payloads (ids only) or full ones? Full is easier to
consume and harder to change.
- Should customers be able to replay an event by hand?

Taking an RFC from Draft to Accepted

An RFC is a Draft while it is being written and reviewed. Rewrite it as freely as you like at that stage; the history is in git.

Review is mostly about the open questions. Each one gets answered, and the answer goes into the RFC, not into a comment thread that disappears when the pull request merges. I move them from Open questions to a Resolved questions section with the reasoning, so the RFC still shows that the question was asked:

## Resolved questions
- **Thin or full payloads: thin, plus the fields a receiver needs to act**
(status, amount, currency). Fully thin forces a fetch for every event;
fully full makes every field in every resource part of the webhook
contract.

Then someone with the authority accepts it. Decide in advance who that is. On a team it is usually the tech lead or whoever owns the area; on my own projects it is me, and the rule that matters is that an agent never moves a document to Accepted on its own judgement.

Once it is Accepted, the RFC is frozen. You do not edit it to keep up with what was built. If the design needs to change, a new RFC supersedes it, and the old one’s status becomes Superseded by RFC 0011. That sounds bureaucratic until the first time someone asks why the system works this way and the answer is sitting in a document nobody has quietly rewritten.

Writing ADRs

Acceptance is when the ADRs get written, one per item in Decisions this records. Here is the template:

# ADR NNNN: The decision, stated as a sentence
- **Status:** Accepted
- **Date:** YYYY-MM-DD
- **From:** RFC NNNN (or none)
## Context
## Decision
## Consequences
## Alternatives

And the first of the webhooks ADRs:

# ADR 0012: Webhooks are signed with Standard Webhooks
- **Status:** Accepted
- **Date:** 2026-10-07
- **From:** RFC 0007
## Context
Receivers need to verify that a request came from us, was not changed in
transit, and is not a replay of one captured earlier. A signature over the
body alone covers the first two. Customers will write receivers in several
languages, and some will already have one for another provider.
## Decision
Every delivery carries `webhook-id`, `webhook-timestamp` and
`webhook-signature` headers as Standard Webhooks defines them: HMAC-SHA256
over `{id}.{timestamp}.{body}`, base64 encoded, prefixed `v1,`. Secrets are
32 random bytes, base64 encoded with a `whsec_` prefix. During rotation,
every delivery is signed with both secrets for 24 hours.
## Consequences
- Receivers can use the official libraries in nine languages.
- Every attempt is signed when it is sent, not when it is queued, so retries
carry a fresh timestamp.
- Bodies are signed as bytes, so payloads must be stored as encoded text
(ADR 0015).
- We cannot add our own fields to the signature without leaving the spec.
## Alternatives
- Our own `X-Signature` over the body: no replay protection without
designing a timestamp scheme ourselves.
- Asymmetric signatures (the spec's `v1a`): receivers could verify without a
shared secret, at the cost of key distribution we do not need yet.

Four rules make an ADR useful a year from now:

The title is the decision, not the topic. “Webhooks are signed with Standard Webhooks”, not “Webhook signing”. Someone scanning the index should learn the decision from the title alone.

Context is the forces, not the history. What made a decision necessary, and what constrained it. Not the story of the meeting where it was argued about.

Consequences include the costs. “We cannot add our own fields to the signature” is the line that will matter in eighteen months, when someone wants to. A list of only good consequences is a sales pitch, and nobody believes it.

One decision per ADR. Signing and retries are two ADRs, because one of them will change and the other will not, and you want to supersede exactly the one that did.

Superseding, not editing

An accepted ADR is never edited. When the decision changes, you write a new ADR and change exactly one line in the old one:

- **Status:** Superseded by ADR 0019

Say the retry schedule turns out to be too short for a customer whose endpoint is down over a long weekend. ADR 0019 says “Delivery is retried for five days”, its context says why three was not enough, and ADR 0013 stays exactly as it was, apart from that status line, with its original reasoning intact. The pair now tells the story of the change, which an edited document never could.

An ADR does not need an RFC. “We use UUIDv7 for public identifiers” can be an ADR the afternoon somebody decides it. What it needs is to be written down before the second pull request that relies on it.

Writing specs

The RFC said what the system should do. A spec says what one piece of work does, precisely enough to build it, review it and test it. This is the same shape as the specs in controlling code quality when an agent writes your Laravel, with one section added for the contract:

# Spec: Deliver webhook events
Implements RFC 0007. Follows ADRs 0012 to 0015.
## Problem
Events are recorded but nothing sends them.
## Behaviour
- When an event is recorded, one delivery job is queued per active endpoint
subscribed to its type, after the transaction commits.
- Each attempt POSTs the stored payload with the three Standard Webhooks
headers, signed at the time of sending, with a 15 second timeout and
redirects not followed.
- A 2xx response is success. A 410 disables the endpoint and stops. A
`Retry-After` in seconds delays the next attempt by that much, at most an
hour. Anything else, including a timeout, is a failure and retried on the
schedule in ADR 0013.
- After the final failed attempt, the endpoint's owner is emailed.
## Boundaries
- No delivery history screen (RFC 0007, non-goals).
- No manual replay yet.
- `Retry-After` as an HTTP date is treated as absent.
## Contract
POST <endpoint url>
Content-Type: application/json
webhook-id: msg_<ULID>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64> [v1,<base64> during rotation]
{"type": "...", "timestamp": "<ISO 8601 UTC>", "data": {...}}
## Acceptance
- A delivery the receiver's verification code accepts is produced for every
event type.
- A retry carries a new timestamp and signature and the same body bytes.
- A 410 disables the endpoint and no further attempts are made.
- A 301 is a failure, not followed.
- An endpoint with two secrets receives two signatures, and either verifies.
- An event recorded in a transaction that rolls back is never delivered.

Notice what is not in it. There is nothing about why it is signed this way, because ADR 0012 says so and the spec points at it. Repeating a decision in a spec means the day the decision changes, the spec is wrong and nobody notices. A spec references decisions; it never restates them.

Behaviour is what happens, in the order it happens, from the outside. Not classes, not method names. If a reviewer could only read this section, they should be able to tell whether the build is right.

Boundaries do for the spec what non-goals do for the RFC: they say what this piece of work will not do, so it does not grow mid-build.

Contract is the public shape: routes for an HTTP feature, headers and payloads here, a CLI’s flags, a function’s signature. It is the part that, left open, gets decided differently in every session, by every person and every agent who touches it.

Acceptance is the section that earns the spec its keep, because each line becomes a test, written against what you described rather than against whatever the code happened to do:

it('signs a retry afresh and sends the same bytes', function () {
Http::fake(['*' => Http::sequence()->push(status: 500)->push(status: 204)]);
// ...deliver twice, then:
[$first, $second] = Http::recorded()->map(fn ($pair) => $pair[0])->all();
expect($second->body())->toBe($first->body())
->and($second->header('webhook-timestamp'))->not->toBe($first->header('webhook-timestamp'));
});

A test written from an acceptance line can fail. A test written from the implementation only ever proves that the implementation does what it does.

When the work ships, the spec has done its job. Leave it in docs/specs/ as a record, or move it to an archive/ folder; either way it stops being something anyone maintains. The RFC and the ADRs are what stay true.

Keeping it light

All of this collapses if the documents get long. Rough sizes I aim for:

  • RFC: two to five pages. Longer usually means it is two RFCs.
  • ADR: half a page. If the context needs more, the decision probably needs an RFC.
  • Spec: one page. More than that and it is more than one piece of work.

And keep the process itself on one page, in docs/README.md: the three documents, the statuses, the numbering, who accepts, and the rule that code follows accepted documents. If the process cannot be explained in a page, nobody will follow it, including you.

Where agents fit

The same documents that let a person build the right thing let an agent build it. An agent given an accepted RFC, the ADRs it produced and a spec with acceptance criteria has very little left to guess, and guessing is where it goes wrong. Two rules keep that safe: agents can draft any of these documents, and only a person accepts them. If you want the layer that sits underneath, rules scoped to the files an agent is about to edit, that is in the August article.

What to do on Monday

Write the ADR for the decision your team keeps arguing about. Every codebase has one: how errors are shaped, where validation lives, whether that service talks to the database directly. Give it a title that states the decision, a context with the actual forces, and consequences that include the cost. The next time it comes up in review, link to it.

Then take the next feature that would change a public contract and write the RFC before anyone opens an editor. Leave the Summary until last, spend the most time on Non-goals, and do not let it be accepted while there is still an open question in it.

Share

XLinkedIn

Related

Keep Reading

All posts →