What Is an Architecture Decision? Recognizing Architecturally Significant Choices
A decision is architectural, and deserves a record, when it is expensive to reverse, reaches several modules or the system boundary, or moves a prioritized quality attribute scenario. We classify ten decisions from Bookstore's first months with these three criteria in C#, and discuss which deserve an ADR, a design doc or a PR description, who owns the decision, and what unrecorded decisions cost.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Below are ten decisions that landed on the Bookstore team's table in its first months. Each one was discussed for real, in a meeting, in a PR (pull request) or in a Slack thread; some took five minutes, some took two weeks. We wrote them down as a C# record: how expensive each one is to reverse, who is affected if it changes, and which of the top three characteristics we picked in the Introduction to Architecture series (availability, performance, modifiability) it moves.
using Bookstore.Decisions;
Decision[] firstQuarter =
[
new("D01", "Start as a modular monolith, one deployment", Reversibility.OneWay, Reach.ManyModules, ["modifiability", "availability"]),
new("D02", "SQL Server as the single database", Reversibility.Costly, Reach.ManyModules, ["performance"]),
new("D03", "Angular SPA in front of an ASP.NET Core API", Reversibility.Costly, Reach.OutsideSystem, ["modifiability"]),
new("D04", "Order e-mail through an in-process queue", Reversibility.Cheap, Reach.OneModule, ["availability", "performance"]),
new("D05", "Public order number format BK-2026-000123", Reversibility.OneWay, Reach.OutsideSystem, []),
new("D06", "Mapperly instead of hand-written mapping in Catalog", Reversibility.Cheap, Reach.OneModule, []),
new("D07", "Outbox for events between Ordering, Inventory, Payment", Reversibility.Costly, Reach.ManyModules, ["availability"], OptionsAreOpen: true),
new("D08", "Redis cache in front of the catalog", Reversibility.Costly, Reach.OneModule, ["performance"], OptionsAreOpen: true),
new("D09", "Rename OrderService.Place to PlaceOrderAsync", Reversibility.Cheap, Reach.OneClass, []),
new("D10", "AsNoTracking in the book details query", Reversibility.Cheap, Reach.OneClass, ["performance"]),
];
How many of these ten are architecture decisions? Which ones deserve a written record, and which can be covered in a few sentences of a PR description? And one more: D04 (handing the order e-mail to an in-process queue) was described in the Introduction to Architecture post "Trade-off Thinking" as a two-way door, easy to reverse. Is it worth recording?
Architecture Decisions and ASRs: Definitions
adr.github.io is the community site that collects the vocabulary of this field and also hosts the MADR (Markdown Architectural Decision Records) template. Its definition: an AD (Architecture Decision) is a justified design choice that addresses an architecturally significant functional or non-functional requirement. That requirement has a name of its own: an ASR (Architecturally Significant Requirement) is a requirement with a measurable effect on a system's architecture and quality. The document that captures one AD with its rationale, trade-offs and consequences is an ADR (Architecture Decision Record), and the set of ADRs in a project is the decision log.
The two ends of the definition are connected. The scenario from the third Introduction to Architecture post, "catalog search p95 < 300 ms at campaign peak", is an ASR: it is measurable and it shapes the architecture, because meeting it brings structural options to the table such as a cache, a read model or a search engine. Choosing one of those options is an AD. Writing the choice down together with the rejected options is an ADR.
So which decisions are "architectural"? Michael Nygard, in his 2011 post "Documenting Architecture Decisions" that introduced ADRs, limits them to five areas: decisions that affect the structure, non-functional characteristics, dependencies, interfaces or construction techniques. Microsoft's Azure Well-Architected Framework documentation offers a shorter filter: choices that affect the system's structure or key quality attributes, or that are hard to reverse. It matches the definition from the first Introduction to Architecture post as well: architecture is the stuff that is perceived as hard to change.
Three Criteria: Cost of Reversal, Reach, Quality Attributes
We reduced these definitions to three questions and turned them into the rest of the program:
foreach (Decision d in firstQuarter)
{
Triage t = DecisionTriage.Classify(d);
string why = t.Reasons.Count == 0 ? "-" : string.Join("; ", t.Reasons);
Console.WriteLine($"{d.Id} {t.Record,-12} {why}");
}
Console.WriteLine();
foreach (IGrouping<RecordKind, Decision> g in firstQuarter.GroupBy(d => DecisionTriage.Classify(d).Record))
Console.WriteLine($"{g.Key,-12} {g.Count()}: {string.Join(", ", g.Select(d => d.Id))}");
namespace Bookstore.Decisions
{
public enum Reversibility { Cheap, Costly, OneWay }
// If the decision changes, who has to change? Ordered from smallest to largest
public enum Reach { OneClass, OneModule, ManyModules, OutsideSystem }
public enum RecordKind { PullRequest, Adr, DesignDoc }
public sealed record Decision(
string Id,
string Title,
Reversibility Reversibility,
Reach Reach,
string[] AffectedQualities,
bool OptionsAreOpen = false);
public sealed record Triage(RecordKind Record, IReadOnlyList<string> Reasons);
public static class DecisionTriage
{
// The top three characteristics chosen in Introduction to Architecture 02-03
private static readonly string[] Prioritized = ["availability", "performance", "modifiability"];
public static Triage Classify(Decision d)
{
List<string> reasons = [];
if (d.Reversibility == Reversibility.OneWay)
reasons.Add("one-way door");
else if (d.Reversibility == Reversibility.Costly)
reasons.Add("costly to reverse");
if (d.Reach == Reach.OutsideSystem)
reasons.Add("crosses the system boundary");
else if (d.Reach == Reach.ManyModules)
reasons.Add("spans modules");
// An improvement that stays inside one class is not architectural
string[] qualities = [.. d.AffectedQualities.Intersect(Prioritized)];
if (qualities.Length > 0 && d.Reach > Reach.OneClass)
reasons.Add($"moves {string.Join("+", qualities)}");
RecordKind record = reasons.Count switch
{
0 => RecordKind.PullRequest,
_ when d.OptionsAreOpen && d.Reach >= Reach.ManyModules => RecordKind.DesignDoc,
_ => RecordKind.Adr,
};
return new Triage(record, reasons);
}
}
}
D01 Adr one-way door; spans modules; moves modifiability+availability
D02 Adr costly to reverse; spans modules; moves performance
D03 Adr costly to reverse; crosses the system boundary; moves modifiability
D04 Adr moves availability+performance
D05 Adr one-way door; crosses the system boundary
D06 PullRequest -
D07 DesignDoc costly to reverse; spans modules; moves availability
D08 Adr costly to reverse; moves performance
D09 PullRequest -
D10 PullRequest -
Adr 6: D01, D02, D03, D04, D05, D08
PullRequest 3: D06, D09, D10
DesignDoc 1: D07
The first criterion is the cost of reversal. In the Introduction to Architecture post "Trade-off Thinking" we met Bezos's one-way/two-way door distinction; here we use it only as an input to classification. D05, the format of the order number shown to customers in e-mails and on shipping labels, is a one-way door: once it lives in customers' inboxes, in the accounting reports and in the carrier's system, changing it is not a migration but a transition project. The "one deployment" decision in D01 is similar; you can go back, but it takes months.
The second criterion is reach: if the decision changes, who has to change? The Reach enum orders that from a single class to beyond the system boundary. D03 (the split between an Angular SPA (Single-Page Application) and an API (Application Programming Interface)) crosses the system boundary, because the mobile app and the second country's UI will use the same API. The database choice in D02 binds the persistence code of every module.
The third criterion is the effect on quality attributes: does the decision change whether one of the prioritized scenarios holds? The Redis cache in D08 moves search latency and availability directly. This criterion has a condition, and that condition is what separates the borderline cases.
Borderline Decisions
Put D04 and D10 side by side. Both are cheap to reverse, both affect performance. The program sends D04 to an ADR and D10 to a PR description. The difference is reach: AsNoTracking is an improvement that stays inside one query and that nobody else needs to know about. If someone removes it tomorrow, only that query slows down, and anyone who has read the performance post in the EF (Entity Framework) Core series understands why from the code itself. D04, on the other hand, changes the behaviour of a module: whether the e-mail was sent after an order is placed is now an intermediate state, e-mails can be lost during a deploy, and customer support has to know that. It is easy to reverse, but the cost it accepts is invisible. Making that cost visible is the real job of an ADR.
The d.Reach > Reach.OneClass condition in the code makes that distinction. Remove it and D10 lands in an ADR too, and in the first week the team revolts against the process: "are we writing an ADR for every query tweak?" Make the criterion too loose and decisions like D04 get lost in PR descriptions. Think of this program not as a decision machine but as a conversation tool: when a new decision comes up it makes you ask the three questions, and if any answer is "yes" it suggests opening a record. The team still has the final word.
Some decisions also move over time. The Mapperly choice in D06 lives in one module today and a PR description is enough. Once three modules use the same mapping library and its license or maintenance status becomes a risk, the decision turns into a cross-module topic. This is exactly the spectrum between architecture and design from the first Introduction to Architecture post: a decision's place is not fixed, it drifts towards architecture as its effect grows.
ADR, Design Doc or PR Description?
The program has three outputs, but in real life there are a few more kinds of record. Our own rule for when to write which:
| Record | When | Length | Bookstore example |
|---|---|---|---|
| PR description | The decision stays in one module, is cheap to reverse and does not move prioritized scenarios | A few sentences | D06, D09, D10 |
| ADR | At least one of the three criteria is yes; the options are known and the debate is short | One or two pages | D01, D02, D03, D04, D05, D08 |
| Design doc, then ADR | An architecture decision whose solution is still unclear; several modules or teams are affected | 2–10 pages | D07 (outbox) |
| RFC (Request for Comments) | The affected audience is outside the team; a written comment process and a dated decision are needed | Like a design doc | None today; when there are more teams |
Why is D07 not an ADR right away? Because the answer to "outbox, the broker's transaction support, or CDC (Change Data Capture)?" is not known yet, and Ordering, Inventory and Payment have to agree on the same design. An ADR is a record of a decision; the discussion needed to make the decision happens elsewhere. That place is the design doc, and it is the topic of the fourth post. The design doc is finished, the decision is made, and the result enters the decision log as a short ADR that links to the design doc.
.NET itself makes the same split. The github.com/dotnet/designs repository is where design proposals for the runtime, the framework and the SDK (Software Development Kit) are collected: each proposal is opened as a pull request, lives as a Markdown file in folders grouped by year such as accepted/2026/, and its template has Goals and Non-Goals headings. C# language decisions live in a separate repository, dotnet/csharplang, as LDM (Language Design Meeting) notes. The rationale of a small bug fix stays in the PR description. The same organization uses three different kinds of record depending on a decision's reach.
Who Owns the Decision?
Saying a decision is architectural does not say who makes it. There are three common models. In the first, the architect decides; consistency is high, but the architect becomes a bottleneck and the team does not own the decisions. In the second, the team decides together; ownership is high, but everything turns into a meeting and consensus becomes impossible as the team grows. The third is the advice process that Andrew Harmel-Law described on martinfowler.com in 2021: anyone can make an architecture decision, as long as they have consulted the people affected by it and the people with expertise in the area. Consulting does not mean having to follow the advice; but asking for it and recording it is mandatory.
In Bookstore's current shape (3 developers, 1 part-time ops engineer) the difference between these models is small; everyone is in every meeting. But the classification also tells you whom to consult. The order number in D05 affects customer support and accounting, and it should not be decided without asking them. The single-deployment decision in D01 sets the ops on-call load, so Linus's view has to be in the record. The wider a decision's reach, the longer its "consulted" list. We will turn the advice process and the architecture advisory forum into a team rhythm in the last post of this series.
The Cost of Unrecorded Decisions
A developer who joined the team eight months later is looking into a customer complaint: "I never got the e-mail for my order." In the code they find a Channel<OrderConfirmation> and a background worker, and they notice that messages left in the queue are lost during a deploy. They make the fix that looks sensible: send the e-mail synchronously inside the checkout request again. In the next campaign the e-mail provider slows down, checkout slows down with it, customers retry and duplicate-order complaints start. The team has recreated the very problem it ran away from on purpose eight months earlier.
Nygard's diagnosis is exactly this: when you meet a decision whose rationale you do not know, you have two options. Either you accept it blindly and keep it even if the circumstances have changed, or you reverse it blindly and break a property the decision was protecting, one that no test catches. git blame tells you who wrote it and when, and the commit message may say "add email queue"; but it does not tell you which options were discussed, which cost was accepted on purpose, and under which condition the decision should be reopened.
Unrecorded decisions have one more cost: the same debate happens again and again. "Should we move to PostgreSQL?" gets reopened with every new developer, because nobody remembers why the team did not move last time. In the next post we will record that debate as a rejected ADR; writing down a rejected decision is at least as valuable as writing down an accepted one.
Common Pitfalls
Treating every decision as architectural. If every query optimization and every method name needs a record, the team finds a way around the process and the important decisions go unrecorded too. D10 belongs in the PR description.
Recording only big, expensive decisions. Decisions like D04, cheap to reverse but with an invisible cost, are the ones most likely to fall victim to "blind reversal". The quality attribute criterion exists for them.
Reducing the decision to a technology name. "We use Redis" is not a decision summary; the actual decision is "we reduce database load and latency of catalog search with a cache, and we accept the consistency cost in this way". The technology is a consequence of the decision.
Not writing down who decided and who was consulted. "We decided" tells nobody anything six months later. Who decided, who was consulted, who was informed: the MADR template in the next post asks for these as separate fields.
Writing the criteria once and forgetting them. If the prioritized characteristics change (a second country, a mobile app), so does which decision is architectural. The Prioritized array is not fixed; it should be updated whenever the utility tree from the Introduction to Architecture series is.
A Small Experiment
★☆☆ Write five decisions from your own project in the last month as Decision records and run the program. Do you agree with the classification? For a result you disagree with, which criterion is missing or too much?
★★☆ Add a string[] named Consulted to Decision and make the program warn when a decision with Reach.OutsideSystem or Reversibility.OneWay has an empty consulted list. Run D05 through that rule.
★★★ In a one-hour session with your team, list ten decisions from the last six months, apply the three criteria to each and mark the ones that should have an ADR but do not. Pick the riskiest one, the one that would be most expensive to reverse if its rationale were forgotten; after reading the next post, write its ADR.
We now know which decisions deserve a record. In the next post we move on to the record itself: writing ADRs with the Nygard template, MADR 4.0 and Y-statements, the status lifecycle, and reviewing an ADR as a pull request.