Architecture decision records: a page per decision
One decision, one page, in the repository next to the code. In a multi-tenant web application every decision is inherited by every tenant, so the page matters more than it looks.
In the summer of 2011, at a securities settlement bank, a developer who had joined two weeks earlier asked me why every table in the incident reporting tool carried a BusinessUnitId as the first column of every index, even the lookup tables with forty rows. I gave him the answer I had, which was "that is how we do it", and then I went looking for the better answer. There was none written down. I had made the decision myself in a meeting the year before, with two people who had since left, and I could no longer remember which alternatives we had rejected or why the lookup tables had been included.
That was the month I started writing a page per decision. Not a design document, not a chapter that grows until nobody reads it. A page. One decision, its context, what we chose, what it costs us, and one line about what would make me change my mind. I put the first thirty of them in the company wiki, because in 2011 that is where documents went, and that is the part I got wrong. Fifteen years later the pages live in the repository, and the product I am responsible for now has 61 of them.
I would not call this a method. It is closer to a habit, like dating a drawing in the corner before you put it in the drawer.
Four parts and a status
The format I use is a small variation on the one Michael Nygard described in 2011, which I found that winter, some months after inventing a worse version of it myself. Every page has a number and a title in the imperative ("ADR-004: Keep all tenants in shared tables with a TenantId column"). Then four sections.
Context is two or three paragraphs about the situation at the time of the decision: how many tenants, what the team knew, what the constraint was. This is the part people skip and the part that matters most in five years, because the situation will have changed and the reader needs to know whether the decision still applies. I write numbers into it. "We had 12 paying tenants and a signed contract for 40 more" tells a reader in 2026 more than "we were small".
Decision is one paragraph, sometimes one sentence. What we chose, stated flatly. If I need more than a paragraph, I have probably written two decisions on one page and should split them.
Consequences is the honest list: what becomes easier, what becomes harder, what we now have to police. The shared-row decision made backups cheap and made "restore one tenant to last Tuesday" a three-day job. Both go on the page. A consequences section that lists only benefits is a sales pitch, and I wrote a few of those before I learned better.
The addition is a single line at the end, under the heading "What would make me revisit this". For the shared-row decision it reads: "A tenant with more than 15 percent of total rows, or a customer contract that requires a separate database." That line has done more work than any other part of the format. It turns a decision from a monument into a tripwire. When the largest tenant crossed 15 percent in 2022, someone found the page, and we had the conversation before the outage instead of after.
Every page also carries a status: proposed, accepted, superseded. Accepted pages are never edited. If the decision changes, a new page supersedes the old one and both link to each other. The history of what you thought is worth as much as what you think now.
They live next to the code
The pages are Markdown files in docs/decisions/ in the same repository as the code they govern. Not a wiki, not a shared drive, not a tool that needs a licence. It took me until 2016 to move them out of the wiki, five years I would like back, and the move was worth doing for three reasons.
First, they travel with the code. When you clone the repository, you get the decisions. When you branch, the decisions branch with you. A developer who opens TenantId in the model can grep for it and find the page in seconds.
Second, they go through pull requests. A proposed ADR is a PR with one file. People comment on the lines, the way they comment on code. The decision gets made in the review, and the merge is the acceptance. This is the only decision-making process I have seen survive a team growing from six to thirty without turning into a meeting.
Third, they get deleted with the code. When a service is retired, its decisions go with it. Nothing is worse than a wiki full of decisions about systems that no longer exist, all still marked accepted. I know, because I left one behind.
Why many tenants raise the stakes
In a product with one customer, a bad decision hurts one customer and you fix it during the next release. In a multi-tenant web application, every decision is inherited by every tenant, including the ones who sign up in three years. You cannot re-decide per customer. The customer who wants their own database is not asking for a feature; they are asking you to reverse ADR-004 for one row of the tenants table, and every consequence on that page is now their consequence too.
Decisions also compound. Shared-row tenancy leads to global query filters, which leads to a rule that every entity implements ITenantScoped, which leads to a convention in the migration pipeline, which leads to a way of testing. By the time you are at the fifth link, nobody remembers that the first one was a choice. The pages keep the chain visible. When someone asks "why do we do it this way", the answer is a number, and the number leads to the reasons.
There is a cost argument too. In 2023 I reviewed a product with 340 tenants and no decision records. The team had seven strongly held opinions about why tenants were sharded across four databases, and none of them matched the code. Reconstructing the reasoning took two weeks of archaeology. Writing the pages at the time would have taken two afternoons.
ADR-004, shortened
Here is the shared-row page as it stands, trimmed to fit. The original is about a screen and a half.
ADR-004: Keep all tenants in shared tables with a TenantId column. Status: accepted, 2017-09-14. Supersedes nothing. Superseded by nothing yet.
Context. Twelve paying tenants, the largest with 1,100 users. Signed pipeline of 40 more. Team of six. We run one Azure SQL database at S3 and the monthly bill is a line item the founders read. Nobody has asked for data isolation beyond what a contract clause provides.
Decision. Every tenant-owned table gets a non-nullable TenantId column, first in every clustered index. EF Core applies a global query filter on TenantId from the resolved tenant. Reference data without a tenant lives in separate tables with no filter.
Consequences. One schema and one migration pipeline. Per-tenant restore requires restoring the whole database to a side copy and extracting rows; we accept this. A missed filter leaks data between tenants; we mitigate with an integration test that runs every query as two tenants and diffs. Noisy neighbours share the same DTUs.
What would make me revisit this. A tenant above 15 percent of total rows, or a contract that requires a separate database.
Notice what is not on the page: no diagram of the schema, no discussion of the EF Core API, no list of the six alternatives. Those live elsewhere and the page links to them. The page is the decision.
What I would change today
Not much, and that surprises me. I would put the pages in the repository from the first day rather than losing five years to a wiki. I would add the "revisit" line from page one rather than discovering it in 2019. I would number pages per repository rather than per product, because products split. And I would stop trying to make people write ADRs for small things; the pages work because there are 61 of them and not 600. If a decision can be undone in an afternoon, it does not need a page. If it will be inherited by a tenant who has not signed yet, it does.
Drawn from
- Maintain an architecture decision recordlearn.microsoft.com
- Tenancy models for a multitenant solutionlearn.microsoft.com
- Multitenant SaaS patterns for Azure SQL Databaselearn.microsoft.com
- Global query filters in EF Corelearn.microsoft.com