From ADR to Automated Check: Verifying Decisions with Fitness Functions and CI
The Confirmation section of every ADR is the specification of a fitness function: a decision is checked on every PR, on a schedule, continuously in production, or lands on a short manual list. At Bookstore we verify the public API decision with oasdiff, the bundle size decision with Angular budgets, migration reversibility with an xUnit test and the .NET support window with a weekly check, all with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Before the mobile app shipped, the Bookstore team made a decision about the public API (Application Programming Interface). The core of the ADR (Architecture Decision Record):
---
status: accepted
date: 2026-07-15
decision-makers: Ada, Grace
consulted: mobile app team, Customer Experience team
informed: all teams
---
# Public API stays compatible within a major version
## Context and Problem Statement
The mobile app and the second country's web app will call /api/v1. A mobile
release takes up to two weeks to reach users, so old app versions keep calling
the API long after a deploy. How do we change the API without breaking them?
## Decision Outcome
Chosen option: "No breaking change inside a major version", because old
mobile clients cannot be updated on our schedule. Breaking changes go to
/api/v2, and v1 stays for at least six months after v2 ships.
### Confirmation
* CI generates the OpenAPI document at build time and runs
`oasdiff breaking` against `openapi/bookstore-v1.json`; any ERR fails the PR.
* The baseline file changes only in a PR labelled "api-release".
Today's v1 API is a small minimal API with three endpoints:
using Microsoft.AspNetCore.Http.HttpResults;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
WebApplication app = builder.Build();
app.MapOpenApi();
BookResponse dune = new(1, "Dune", "Frank Herbert", 18.49m, "EUR");
RouteGroupBuilder books = app.MapGroup("/api/v1/books");
books.MapGet("/{id:int}", Results<Ok<BookResponse>, NotFound> (int id) =>
id == dune.Id ? TypedResults.Ok(dune) : TypedResults.NotFound());
books.MapGet("/", (string? query, int page = 1) => TypedResults.Ok(new[] { dune }));
app.MapPost("/api/v1/orders", (PlaceOrderRequest request) =>
TypedResults.Created("/api/v1/orders/1042", new OrderResponse(1042, request.BookId, request.Quantity)));
app.Run();
public sealed record BookResponse(int Id, string Title, string Author, decimal Price, string Currency);
public sealed record PlaceOrderRequest(int BookId, int Quantity);
public sealed record OrderResponse(int OrderId, int BookId, int Quantity);
A PR (pull request) changes three lines: Price is renamed to UnitPrice, the book list gets a required currency parameter, and Isbn is added to the response.
-BookResponse dune = new(1, "Dune", "Frank Herbert", 18.49m, "EUR");
+BookResponse dune = new(1, "Dune", "Frank Herbert", 18.49m, "EUR", "9780441013593");
-books.MapGet("/", (string? query, int page = 1) => TypedResults.Ok(new[] { dune }));
+books.MapGet("/", (string? query, string currency, int page = 1) => TypedResults.Ok(new[] { dune }));
-public sealed record BookResponse(int Id, string Title, string Author, decimal Price, string Currency);
+public sealed record BookResponse(int Id, string Title, string Author, decimal UnitPrice, string Currency, string? Isbn);
The build passes, the unit tests pass, and the Angular app works too because it was updated in the same PR. Which of these three changes break the mobile app that shipped to the store two weeks ago? Who notices in code review?
Reading the Confirmation Section
In the second post we saw the Confirmation section of MADR (Markdown Architectural Decision Records) 4.0: "how will we know the decision was implemented?" The Introduction to Architecture post "Keeping Architecture Healthy Over Time" gave one answer to that question under the name fitness function: any mechanism that objectively assesses whether one or more architecture characteristics still hold. The same post classified fitness functions along the atomic/holistic, triggered/continuous and static/dynamic axes, and the eighth post of the Clean, Onion and Hexagonal Architecture series turned dependency rules into tests with ArchUnitNET. This post does not repeat that foundation; it makes the connection: the Confirmation section of every ADR should be the specification of a fitness function.
In practice this means: when reviewing an ADR, look at Confirmation and look for an answer to one of three questions. Can this decision be checked automatically on every PR (triggered)? Does it need to be checked at intervals (temporal)? Does it need to be watched continuously in production (continual)? If none of these, the check lands on a person's checklist. Examples from Bookstore's decision log:
| Decision | Check | When | Kind |
|---|---|---|---|
| ADR-0003 modular monolith | ArchUnitNET rule: modules do not use each other's internal types | Every PR | Triggered, structural |
| ADR-0010 public API compatibility | OpenAPI diff, red at ERR level | Every PR | Triggered |
| ADR-0014 initial bundle < 250 kB | Angular budget, maximumError | Every production build | Triggered |
| ADR-0013 reversible migrations | Every migration's Down is filled or justified | Every PR | Triggered |
| ADR-0015 supported LTS (Long Term Support) .NET | At least 180 days until end of support | Weekly | Temporal |
| ADR-0008 catalog cache | Database queries per 1,000 searches, stale price duration | Nightly load test, production metric | Temporal and continual |
| ADR-0011 outbox | No personal data in payloads | Every PR, in review | Manual |
The first row of the table is a structural check, and we saw it in the Clean, Onion and Hexagonal series; rules specific to module boundaries will be covered in the upcoming Modular Monolith series. In this post we look at the non-structural ones.
OpenAPI Diff: Tying the Public API Decision to CI
Checking ADR-0010 needs two things: the current OpenAPI document generated during the build, and a baseline kept in the repository. In .NET 10 the Microsoft.Extensions.ApiDescription.Server package writes the document defined with AddOpenApi to a file during the build. The project file:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../artifacts/openapi</OpenApiDocumentsDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
OpenApiDocumentsDirectory says where the file is written. When v1 shipped, the generated file was committed as openapi/bookstore-v1.json; that file is the contract itself. For the comparison we used oasdiff, the open-source tool started by Tufin, with the Docker image of version 1.36.0. The real output for the opening PR:
$ oasdiff breaking openapi/bookstore-v1.json artifacts/openapi/Bookstore.Web.json --fail-on ERR
3 changes: 3 error, 0 warning, 0 info
error [new-required-request-parameter] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books
added the new required `query` request parameter `currency`
error [response-required-property-removed] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books
removed the required property `items/price` from the response with the `200` status
error [response-required-property-removed] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books/{id}
removed the required property `price` from the response with the `200` status
exit code: 1
Two of the three changes are breaking. Adding a required query parameter breaks every old client that does not send it. Renaming Price is a rename refactoring on the C# side, but on the JSON (JavaScript Object Notation) side it means the price field disappears; the old mobile app shows an empty price. Why does code review miss this? Because the diff only changes a record's parameter name, and since the Angular client was updated in the same PR, everything looks like it works. The client that breaks is not in the repository, it is on users' phones.
The version that follows the ADR: Price stays, currency becomes optional (string? currency), Isbn is added:
$ oasdiff breaking openapi/bookstore-v1.json artifacts/openapi/Bookstore.Web.json --fail-on ERR
No breaking changes to report, but the specs are different.
Run 'oasdiff diff' to see structural differences.
exit code: 0
$ oasdiff changelog openapi/bookstore-v1.json artifacts/openapi/Bookstore.Web.json
3 changes: 0 error, 0 warning, 3 info
info [new-optional-request-parameter] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books
added the new optional `query` request parameter `currency`
info [response-required-property-added] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books
added the required property `items/isbn` to the response with the `200` status
info [response-required-property-added] at artifacts/openapi/Bookstore.Web.json
in API GET /api/v1/books/{id}
added the required property `isbn` to the response with the `200` status
The changelog command also lists the non-breaking changes; it is a good source for the PR description. One detail caught our attention: string? Isbn was marked "required" in the schema despite being nullable, because it is a constructor parameter of a positional record. Adding a new required field to a response does not break clients, but removing that field later would be breaking. So every field added today is a commitment carried for the whole life of v1.
The CI (Continuous Integration) step:
name: decision-checks
on: pull_request
jobs:
api-compatibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- run: dotnet build src/Bookstore.Web
# ADR-0010: the PR turns red if the v1 contract breaks
- run: >
docker run --rm -v "$PWD:/specs" -w /specs tufin/oasdiff:v1.36.0
breaking openapi/bookstore-v1.json artifacts/openapi/Bookstore.Web.json --fail-on ERR
We ran the commands and scenarios locally; we did not run the workflow on GitHub for this post. The second Confirmation bullet in the ADR matters too: the baseline file can only change in a PR labelled "api-release". Otherwise the easiest way to get a breaking change through is to update the baseline; we apply the CODEOWNERS rule from the second post to the openapi/ folder as well.
Angular Budgets: The Bundle Size Decision
Most users in the second country come over mobile networks, and the team made a decision for that: ADR-0014, the initial bundle of the Angular app does not exceed 250 kB. We saw budgets in the "Angular Build and Deploy" post of the Angular series; here we use them as the Confirmation of an ADR. The budgets in the production configuration of angular.json in a project created with Angular 22.2, with the decision's numbers:
{
"budgets": [
{
"type": "initial",
"maximumWarning": "230kB",
"maximumError": "250kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
]
}
The empty project is under the limit at 217.10 kB. A PR uses lodash's chunk function to group the books on the shelves in pairs:
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import _ from 'lodash';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
styleUrl: './app.css',
templateUrl: './app.html',
})
export class App {
protected readonly title = signal('bookstore-web');
protected readonly shelves = _.chunk(['Dune', 'Emma', 'The Hobbit', 'Middlemarch'], 2);
}
The real output of ng build with Node 24:
✔ Building...
Initial chunk files | Names | Raw size | Estimated transfer size
main-XNBDORRN.js | main | 291.50 kB | 83.67 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
| Initial total | 291.50 kB | 83.67 kB
Application bundle generation failed. [1.009 seconds] - 2026-11-08T00:47:56.296Z
▲ [WARNING] bundle initial exceeded maximum budget. Budget 230.00 kB was not met by 61.50 kB with a total of 291.50 kB.
▲ [WARNING] Module 'lodash' used by 'src/app/app.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies
✘ [ERROR] bundle initial exceeded maximum budget. Budget 250.00 kB was not met by 41.50 kB with a total of 291.50 kB.
exit code: 1
The whole library went into the bundle for a single function: 74.4 kB. maximumWarning gave a yellow warning, maximumError stopped the build. The warning in between is a hint on its own: lodash is CommonJS, so tree-shaking does not work. This is exactly where the budget earns its value; the rule is not "do not use lodash" but "the initial bundle does not exceed 250 kB". The developer can choose an alternative distributed as an ES (ECMAScript) module, move the code to a lazy-loaded route, or write the two-line function themselves. The decision protects an outcome; it does not impose a solution.
Dependencies and Migrations
We covered NuGetAudit for dependency decisions in detail in the A03 post of the OWASP (Open Worldwide Application Security Project) Top 10 series. As the Confirmation of an ADR, the only difference is that the level at which a warning stops the build is written in the decision itself:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>high</NuGetAuditLevel>
<WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup>
</Project>
Bookstore.Audit.csproj : error NU1903: Warning As Error: Package 'Newtonsoft.Json' 12.0.3 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
exit code: 1
While preparing this post we had a surprise: Microsoft.EntityFrameworkCore.SqlServer 10.0.14, which we added for the migration example below, brought in System.Security.Cryptography.Pkcs 9.0.11 through Microsoft.Data.SqlClient 6.1.7, and restore raised a high-level NU1903 warning for that package. If the rule is a fixed threshold, a warning like that can turn every PR red one morning; the ADR should also say who does what, and how quickly.
For migrations the decision is ADR-0013: every migration can be rolled back, and if it cannot, the reason is written down. Why does it matter? When a problem shows up after a deploy, the rollback plan is usually "deploy the previous version, roll back the migration", and an empty Down silently cancels that plan. EF (Entity Framework) Core's Migration class exposes the operations produced by Down through the DownOperations property; we can check it without connecting to a database:
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Data;
public sealed class BookstoreDbContext(DbContextOptions<BookstoreDbContext> options) : DbContext(options);
// A migration whose Down is deliberately empty carries its reason and the related ADR
[AttributeUsage(AttributeTargets.Class)]
public sealed class IrreversibleAttribute(string reason) : Attribute
{
public string Reason { get; } = reason;
}
using System.Reflection;
using Bookstore.Data;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Bookstore.ArchitectureTests;
// ADR-0013: every migration can be rolled back; if not, the reason is written with [Irreversible]
public sealed class MigrationTests
{
[Fact]
[Trait("Category", "Architecture")]
public void Every_migration_can_be_rolled_back_or_says_why_not()
{
string[] violations =
[
.. typeof(BookstoreDbContext).Assembly.GetTypes()
.Where(t => t.IsSubclassOf(typeof(Migration)) && !t.IsAbstract)
.Where(t => t.GetCustomAttribute<IrreversibleAttribute>() is null)
.Where(t => ((Migration)Activator.CreateInstance(t)!).DownOperations.Count == 0)
.Select(t => t.GetCustomAttribute<MigrationAttribute>()!.Id)
];
Assert.True(violations.Length == 0,
$"Migrations with an empty Down and no [Irreversible] reason: {string.Join(", ", violations)}");
}
}
A migration that merges two columns cannot be rolled back easily by nature:
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Bookstore.Data.Migrations;
[DbContext(typeof(BookstoreDbContext))]
[Migration("20261020141500_MergeAuthorNames")]
public sealed class MergeAuthorNames : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>("FullName", "Authors", maxLength: 200, nullable: true);
migrationBuilder.Sql("UPDATE Authors SET FullName = FirstName + ' ' + LastName");
migrationBuilder.DropColumn("FirstName", "Authors");
migrationBuilder.DropColumn("LastName", "Authors");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
failed Bookstore.ArchitectureTests.MigrationTests.Every_migration_can_be_rolled_back_or_says_why_not (5ms)
Migrations with an empty Down and no [Irreversible] reason: 20261020141500_MergeAuthorNames
at Bookstore.ArchitectureTests.MigrationTests.Every_migration_can_be_rolled_back_or_says_why_not() in ./tests/Bookstore.ArchitectureTests/MigrationTests.cs:23
./tests/Bookstore.ArchitectureTests/bin/Debug/net10.0/Bookstore.ArchitectureTests.dll (net10.0|arm64) failed with 1 error(s) (495ms)
failed: 1
The test does not force the migration to be reversible; it forces you to write down the reason. Once the reason is added, the test passes:
[Irreversible("Merges two columns into one; roll back by restoring the backup taken before deploy (ADR-0013).")]
Checks like these share one property: there are two ways to pass. Either you implement the decision, or you write down the exception to it, with its reason, in a visible place. Silencing the check (a Skip, a raised threshold) shows up in a PR and gets reviewed.
Temporal and Continual Checks
Some decisions break not with code but with time. ADR-0015 says Bookstore always stays on a supported LTS .NET release. Even if the code never changes, this decision is violated one day: support ends. The fitness functions that Building Evolutionary Architectures calls temporal are for this: checks that run at a given time or interval. A small program that reads .NET's official release metadata:
using System.Net.Http.Json;
using System.Text.Json.Serialization;
// Confirmation of ADR-0015: the .NET version we target must have at least 180 days of support left
string channel = args.ElementAtOrDefault(0) ?? "10.0";
int minDays = int.Parse(args.ElementAtOrDefault(1) ?? "180");
DateOnly today = DateOnly.FromDateTime(TimeProvider.System.GetUtcNow().UtcDateTime);
using HttpClient http = new();
ReleaseIndex index = (await http.GetFromJsonAsync<ReleaseIndex>(
"https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json"))!;
Release release = index.Releases.Single(r => r.Channel == channel);
int daysLeft = release.EolDate.DayNumber - today.DayNumber;
Console.WriteLine($".NET {channel} ({release.Type}, {release.Phase}): end of support {release.EolDate:yyyy-MM-dd}, {daysLeft} days left");
if (daysLeft < minDays)
{
Console.WriteLine($"FAIL: less than {minDays} days of support left; plan the upgrade (ADR-0015)");
return 1;
}
return 0;
public sealed record ReleaseIndex([property: JsonPropertyName("releases-index")] Release[] Releases);
public sealed record Release(
[property: JsonPropertyName("channel-version")] string Channel,
[property: JsonPropertyName("release-type")] string Type,
[property: JsonPropertyName("support-phase")] string Phase,
[property: JsonPropertyName("eol-date")] DateOnly EolDate);
$ dotnet run -- 10.0 180
.NET 10.0 (lts, active): end of support 2028-11-14, 737 days left
exit code: 0
$ dotnet run -- 8.0 180
.NET 8.0 (lts, maintenance): end of support 2026-11-10, 2 days left
FAIL: less than 180 days of support left; plan the upgrade (ADR-0015)
exit code: 1
On the day this post was written, .NET 8 had two days of support left; the same date applies to .NET 9. A team still on .NET 8 would have seen this as red work at least six months earlier with a weekly check. The program runs on a schedule, not on PRs:
name: decision-schedule
on:
schedule:
# Every Monday at 06:00 UTC
- cron: "0 6 * * 1"
jobs:
support-window:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
# ADR-0015: the target .NET version must have at least 180 days of support left
- run: dotnet run --project tools/Bookstore.SupportCheck -- 10.0 180
Continual checks run continuously in production. The "stale price at most 60 seconds" point of ADR-0008 is verified not by a test but by a synthetic check that compares the price returned from the cache with the price in the database in production; "search p95 < 300 ms at campaign peak" is a matter for an SLO (Service Level Objective). Setting these up and operating them with an error budget is the job of the upcoming SRE (Site Reliability Engineering) series. Architecturally, our responsibility is to write the metric's name and threshold in the ADR's Confirmation section, so that when the alert is set up it is clear which decision it serves.
Decisions That Cannot Be Checked: A Manual Checklist
Not every decision can be automated. You could try to catch "no personal data in the outbox payload" with a regex, but noticing that a customer name ended up in a Note field is a human's job. For decisions like that, Bookstore's PR template has a short list:
## Architecture decisions touched by this PR
- [ ] ADR-0003: no module reads another module's tables or internal types
- [ ] ADR-0008: checkout never uses a cached price
- [ ] ADR-0011: new events go through the outbox; payloads carry no personal data
- [ ] None of the above apply to this PR
Keeping the list short is deliberate. Putting every ADR here produces a form nobody reads and everybody ticks entirely. Only decisions that cannot be automated and are violated often go in; when an item turns into an automated check, it leaves the list. The list itself is a signal too: it shows which decisions are still left to human attention.
Common Pitfalls
Leaving Confirmation empty or as "code review". "We'll look at it in review" is not a check, it is a hope. Either write a mechanism, or write why the decision cannot be checked and who looks at it.
Separating the check from the decision. A budget or an OpenAPI rule without a rationale gets loosened at the first red build. Every check's message or comment should carry the number of the related ADR; all the examples in this post do.
Not protecting the baseline. If the easiest way to get a breaking change through is to regenerate bookstore-v1.json, the check means nothing. Changing the baseline should need a separate approval.
Checking the solution instead of the decision. A "lodash is forbidden" rule misses the next big library; an "initial bundle < 250 kB" rule catches all of them. The check should measure the outcome in the ADR's Decision Outcome.
Forgetting decisions that break with time. Support windows, certificates, licenses and deprecated APIs break without any code change. Without a scheduled check, your first notice comes from an incident.
Inflating the checklist. A checkbox for every ADR ends with boxes ticked without reading. Keep it short, and make it shorter as you automate.
A Small Experiment
★☆☆ Pick five ADRs from your own decision log and fill in the table from this post: check, when, kind. How many can be automated, and how many have to stay manual?
★★☆ Try the following changes one by one with oasdiff and note which are ERR and which are WARN or INFO: removing an endpoint, changing a response field's type from int to string, making a request field required instead of optional, adding a new 404 response.
★★★ Add a second rule to MigrationTests: every migration that drops a table or column must have been marked "no longer used" not in the same release but in the previous one (expand/contract). Decide where to keep that information, and write your decision down as an ADR.
Our decisions are now written, debated, documented, and some of them are protected automatically. What is left is the hardest part: all of this still working a year from now. In the last post of the series we talk about keeping the decision log discoverable, "is this decision still valid?" triggers, a lightweight architecture review and the advice process.