RFCs and Design Docs: Debating Big Decisions in Writing
A decision whose solution is unclear and that affects several teams is first debated in a design doc, with an RFC process with written rules when needed, and its outcome is recorded as a separate, immutable ADR. Using a two-page design doc for adding an outbox to Bookstore's order service: Context and Scope, measurable Goals, real Non-Goals, Alternatives Considered, cross-cutting concerns, the lifecycle and the RFC comment period.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In Bookstore, order e-mails get lost on every deploy; a cost accepted on purpose in the Introduction to Architecture series became unacceptable once Inventory and Payment needed the same event. Ada started writing a design doc. The Goals and Non-Goals sections of the first draft look like this:
## Goals
* Order events reach Inventory, Payment and Notification after the order is saved
* Fast checkout
## Non-Goals
* The system must not lose events
* No downtime during deployment
* Exactly-once delivery to consumers
* Moving Bookstore to microservices
Which of the four items under Non-Goals are really non-goals? Which are actually goals written as negative sentences? Which of the two items under Goals cannot answer a reviewer's question "how will we measure this?"
What a Design Doc Is and Is Not
Malte Ubl's 2020 post "Design Docs at Google" defines a design doc as an informal document written before coding starts that records the high-level strategy of the solution and, most importantly, the trade-offs in that strategy. The key word is trade-off. The job of a design doc is not to describe a solution but to open up for discussion, while no code has been written and change is cheap, why one of several reasonable solutions was chosen.
This is where it differs from an ADR (Architecture Decision Record). An ADR is the record of a decision that has been made: short, focused on the decision itself, and unchanged once decided. A design doc is the tool for the discussion needed to make the decision: longer, going into the details of the design, and changing constantly during review. That is why, in the classification from the first post, D07, the outbox decision, went to a design doc first instead of straight to an ADR: three modules were affected and the solution was still unclear.
Ubl also says when not to write a design doc. If the solution is undisputed, that is, there is only one reasonable way, writing a design doc turns into writing an implementation manual and nobody reads it. When rapid prototyping is needed and the problem can only be understood by trying, a document is a waste of time too; the cache spike in the previous post is an example. His length guidance is 10–20 pages for large projects and a 1–3 page "mini design doc" for small pieces of work. At Bookstore's scale almost every design doc should be a mini one.
Context and Scope, Goals and Non-Goals
The first page of the outbox design doc, as the draft looked after review:
# Order events through a transactional outbox
Status: In review (comments until 2026-10-09)
Authors: Ada, Grace · Reviewers: Linus (ops), Fulfillment team, Alan
Decision record: ADR-0011 (to be written after review)
## Context and Scope
Ordering saves the order and then hands OrderPlaced to an in-process queue
(ADR-0004). Messages in the queue are lost when the process stops; we see
3-12 lost e-mails per weekly deploy. Inventory and Payment now need the same
event to reserve stock and capture payment, and losing it there means an
order that is never shipped or never charged. This document covers how
Ordering publishes order events reliably inside the modular monolith. It does
not cover how consumers process them.
## Goals
* Every committed order produces exactly one OrderPlaced record, in the same transaction
* Consumers receive the event at least once, within 5 seconds at p95
* A deploy or crash loses no events
* Checkout p95 stays under 800 ms
## Non-Goals
* Exactly-once delivery: consumers must be idempotent (Inbox, see Messaging series)
* A message broker: we stay in-process until a second deployment unit exists
* Event versioning and schema registry
* Moving existing events of Catalog and Inventory to the outbox
Context and Scope is short and objective: what is happening, why it is a problem now, what the document covers and what it does not. Ubl's warning matters here: this section is not a requirements document, it is the minimum background the reader needs to understand the rest. It has numbers (3–12 lost e-mails per deploy), because the size of the problem determines how much complexity the solution deserves.
Look at Goals: "Fast checkout" is gone, replaced by the measurable scenario from the third Introduction to Architecture post (checkout p95 < 800 ms). "Events reach consumers" became "at least once, within 5 seconds at p95". An easy way to tell whether a goal is measurable is to imagine the test or metric that would verify it; if you cannot imagine one, you have written a wish, not a goal.
In Non-Goals two items from the opening draft disappeared. "The system must not lose events" is not a non-goal, it is a goal written as a negative sentence; in the new Goals list it reads "A deploy or crash loses no events". "No downtime during deployment" is the same; on top of that it is not the subject of this design doc and belongs in neither list. Ubl's definition is clear: a non-goal is something that could reasonably be a goal but is deliberately left out. The example in his post is making ACID (Atomicity, Consistency, Isolation, Durability) compliance a non-goal for a database design: a reasonable target, but deliberately not chosen for this system. In our list "exactly-once delivery" is exactly such an item. Someone might say "if we are adding an outbox, let's do exactly-once too"; writing it as a non-goal closes that debate before the review and states the consequence: consumers have to be idempotent.
In the proposal template of .NET's dotnet/designs repository, Goals and Non-Goals are separate headings too. The template's description of Non-Goals says the same thing: problems people might think you are trying to solve but that you deliberately scope out. The template also notes that this list usually grows after early feedback; every "no" to the review question "are you going to solve this too?" is a non-goal item.
Design and Alternatives Considered
## Design
PlaceOrder writes the order and an OutboxMessage row in one SaveChanges call.
A background dispatcher reads unprocessed rows in id order, publishes them to
the in-process bus and marks them processed. Rows older than 7 days are deleted.
ordering.OutboxMessages
Id bigint identity -- dispatch order
Type nvarchar(200) -- e.g. Bookstore.Ordering.OrderPlaced
Payload nvarchar(max) -- JSON, no personal data
OccurredUtc datetime2
ProcessedUtc datetime2 null
The dispatcher runs in every instance; rows are claimed with an UPDATE
that uses the UPDLOCK and READPAST hints, so two instances never publish
the same row at the same time. It can still publish a row twice after a crash between
publish and mark-processed; that is why consumers must be idempotent.
## Alternatives Considered
* Keep the in-process queue (ADR-0004): no new table, but losses continue
and Inventory and Payment cannot accept them.
* Publish to a broker inside the database transaction: needs a broker with
distributed transactions; we have no broker and do not want 2PC.
* Change data capture on the Orders table: no code in Ordering, but it
exposes table structure as the contract and needs CDC operations work.
The Design section starts with an overview and goes into details afterwards. Data storage is written in rough form; there is no full schema, no indexes and no migration, because those are not what the review is about. Of the API (Application Programming Interface) only the part relevant to the trade-off is there. The only piece of code we put into the design doc is a sketch of the interfaces the rest of Ordering will see; we checked that it compiles in the Bookstore.Ordering project:
namespace Bookstore.Ordering;
public sealed record OutboxMessage(long Id, string Type, string Payload, DateTime OccurredUtc, DateTime? ProcessedUtc);
public interface IOutbox
{
// The row is written in the same SaveChanges call as the order; no separate transaction
void Add<TEvent>(TEvent domainEvent) where TEvent : notnull;
}
public interface IOutboxDispatcher
{
// Publishes unprocessed rows in Id order and returns how many rows it published
Task<int> DispatchPendingAsync(int batchSize, CancellationToken ct);
}
Ubl's rule: code and pseudo-code should be rare in a design doc, used only to explain a novel algorithm or the shape of an interface. Here the shape of the interface matters, because Add not opening a transaction, that is, taking part in the same SaveChanges as the order, is the point that the whole guarantee of the design rests on.
Alternatives Considered is the part Ubl counts among the most important sections. Each alternative has two things: why it is reasonable, and which trade-off kept it from being chosen. The CDC (Change Data Capture) alternative is not a bad idea; it would work without adding any code to Ordering. It was rejected because the table structure would turn into a contract, and because of the new operations work. That line answers someone who asks "why didn't we use CDC?" six months later, even before the ADR, which is the topic of the next step. We covered the transactional outbox itself, the dual write problem and the inbox side in detail in the outbox post of the Messaging and Event-Driven Architecture series; the design doc does not explain it again, it links to it.
Cross-Cutting Concerns and Rollout
## Cross-Cutting Concerns
* Security: the dispatcher uses the application's database login; no new
credentials.
* Privacy: payloads carry ids, never e-mail addresses or names. Notification
reads contact data from Customer when it sends.
* Observability: metrics outbox.pending (rows not processed) and
outbox.lag_seconds (age of the oldest pending row); alert when lag > 60 s.
* Operations: deleting processed rows is a scheduled job; table size is on
the database dashboard.
## Rollout
1. Add the table and the dispatcher behind a feature flag (off).
2. Write to both the queue and the outbox for one week, compare counts.
3. Switch Notification, then Inventory and Payment, to the outbox.
4. Remove the in-process queue; ADR-0011 supersedes ADR-0004.
## Open Questions
* Is 7 days of retention enough for support investigations?
The cross-cutting concerns section runs the design through questions every design doc should ask: security, privacy, observability. Ubl writes that at Google a separate privacy design doc and separate reviews are mandatory; at Bookstore's scale one bullet is enough, but the bullet must not be left empty. The privacy bullet changed the design: in the first draft the payload carried the customer's e-mail address. The outbox table is kept for seven days, goes into backups and may be opened to other consumers later; putting personal data there would have been a typical case of the data sprawl we discussed in the OWASP (Open Worldwide Application Security Project) Top 10 series. The observability bullet makes a new failure mode visible: if the dispatcher stops, nothing throws an error, only outbox.lag_seconds grows.
The rollout plan keeps the decision reversible step by step: a feature flag, one week of parallel writes with count comparisons, moving consumers one at a time. In the language of the first post, it splits what looks like a one-way door into two-way steps. Open Questions collects the answers explicitly requested from reviewers.
Lifecycle: Draft, Review, Implementation, Maintenance
Ubl's post describes four stages of a design doc. In the draft stage the author, possibly with a co-author, iterates quickly on the document with a few people who know the topic. In the review stage the document is opened to a wider audience; this can be as light as sending it to a mailing list and waiting for comments or as heavy as a formal meeting with senior engineers. In the implementation stage the document is not considered finished: when reality diverges from the plan, the document is updated too. In the maintenance stage it remains an entry point for people who want to understand the system; Ubl suggests authors go back to their documents a year or two later and see what they predicted right and wrong.
At Bookstore the outbox design doc went like this: Ada and Grace wrote the draft in two days and sat with Linus for an hour to fill in the cross-cutting concerns together. The document was opened as a pull request, the Fulfillment team and Alan were added as reviewers, and the comment period was one week. Three important changes came out of the review: the privacy bullet, the corrected non-goals and the one-week parallel write step in the rollout. During implementation the dispatcher's row locking approach changed and the document was updated. The decision itself was written to a separate file as ADR-0011; the design doc's status became "Implemented" and it got a link from the ADR.
That a design doc, unlike an ADR, can change is a deliberate choice. The ADR is the immutable record of the decision; the design doc is the living description of the design. Trying to keep both in one file breaks either the immutability of the ADR or the freshness of the design doc.
The RFC Process: Comment Period, Decision and ADR
If it is clear who will review the design doc, the flow above is enough. When the audience affected by the decision spreads beyond the team, or when it is not even clear who is affected, the process is called an RFC (Request for Comments) and its rules are written down. The name comes from the internet itself: Steve Crocker's RFC 1 of 7 April 1969 was a network protocol note that invited discussion, and that tradition continues all the way to today's internet standards. The RFC in software teams is a small-scale adaptation of the idea: a written discussion that anyone can comment on, with a set duration and named deciders.
Rust's RFC process is a well-known example: when an RFC reaches the decision stage, a final comment period (FCP) starts and lasts ten calendar days, so that it covers at least five working days. In dotnet/designs a proposal is opened as a pull request and merged once broad agreement is reached. The process Bookstore will use once there are more teams is five steps in docs/rfcs/README.md in the repository:
# RFC process
1. Copy docs/rfcs/0000-template.md to docs/rfcs/NNNN-short-title.md and
open a pull request with the "rfc" label.
2. Announce the PR in the #architecture channel with a comment deadline,
at least 5 working days away.
3. Anyone may comment; the author answers every comment in the PR.
4. On the deadline the deciders named in the RFC decide: accepted,
rejected or postponed. The outcome is written as the last PR comment.
5. The RFC is merged in every case. Accepted RFCs get an ADR that links
back to them; rejected and postponed ones keep their reason.
Each of the three rules closes a problem. The comment period makes treating silence as consent legitimate; "nobody objected" only means something if there was time to object. Named deciders keep the RFC from turning into an endless debate. Merging a rejected RFC too follows the same logic as the rejected ADR in the second post: when the same debate comes up six months later, the first RFC and the reason for rejecting it can be found.
How ADRs, RFCs and Design Docs Relate
| Document | Purpose | When it changes | Where it lives at Bookstore |
|---|---|---|---|
| Design doc | Open the solution and its trade-offs for discussion | Throughout review and implementation | docs/design/ |
| RFC | A process with written rules that opens a design doc beyond the team | Until the comment period ends | docs/rfcs/, through a pull request |
| ADR | A short, immutable record of the decision made | Never; it is superseded by a new one | docs/decisions/ |
The flow is one-way: the design doc carries the discussion, the RFC sets the rules of that discussion when needed, and the ADR records the result. Every ADR links to the design doc or RFC that produced it, and the other way round. Most small decisions skip both and become an ADR directly; of the ten decisions in the first post only one needed a design doc.
Common Pitfalls
Mistaking a negative goal for a non-goal. "The system must not crash" and "data must not be lost" are not non-goals, they are goals written as negative sentences. A non-goal is something that could reasonably be a target but is deliberately left out.
Unmeasurable goals. "Fast checkout" cannot be debated in review or verified after implementation. Use the scenario pattern from the Introduction to Architecture series.
Leaving Alternatives Considered empty or for show. A design doc with a single option is either unnecessary or a document that has already made the decision. Either way the review is wasted.
Turning the design doc into an implementation manual. Lists of classes, every endpoint and migration scripts make the document longer but add nothing to discuss. Ubl's test: no trade-offs, no design doc.
Leaving the decision inside the design doc. As the document changes, "what did we finally decide?" goes unanswered. Write the decision into a separate, immutable ADR.
Comments without a deadline. A review without an end date turns into waiting for the quietest person's approval. The date and the decider belong in the first lines of the document.
A Small Experiment
★☆☆ Classify the four non-goal items of the opening draft: real non-goal, goal written negatively, out-of-scope topic. Then find a design doc or a large PR (pull request) description from your own project and do the same classification.
★★☆ Add a fourth alternative to the outbox design doc: "call Inventory and Payment synchronously from Ordering". In one paragraph, write why it is reasonable and which goal it violates, which is why it was not chosen.
★★★ For a large change pending in your team, write a 2-page mini design doc: Context and Scope, measurable Goals, at least two real Non-Goals, two alternatives, and security, privacy and observability bullets. Open it as a PR with a one-week comment period; when the period ends, record the decision as an ADR and link it to the design doc.
We have made, written and debated the decisions. But the diagrams still live in a drawing file on someone's laptop, and there is no structure like arc42 either. In the next post we move on to documenting the architecture: the sections of the arc42 template, docs as code, and keeping C4 (Context, Container, Component, Code) diagrams in the same repository as the ADRs.