In the inventory, you found decisions that would be hard to change today. Do you still know, for each one, why it came out that way? What else there could have been? What was given up for it? In most systems, the honest answer is: The person who was there at the time knows, and they may no longer be on the team. Then the same question gets discussed anew every year, or somebody changes a decision without knowing its reason and breaks something that exactly this reason protected.

What helps is an Architecture Decision Record, ADR for short: a short note per decision that records context, options, decision and consequences. Microsoft's guide calls the ADR one of an architect's most important work products. In this lesson you learn to prepare a decision so that it makes a good ADR, and to keep the log so that it is still right in a year. At the end, docs/adr/ holds a template, five ADRs and a test that checks their form.

Preparing a decision

A good decision doesn't begin with the solution, but with the question. “Should we use Service Bus?” is not a question, but an answer looking for a question. The question behind it reads something like: “How do we keep the import from running twice when the web application has two replicas?” Only this form allows several answers.

Then you collect options. Three rules:

  • At least two real options. A real option is one you would seriously take if the circumstances were slightly different. An option that is written down only to make the preferred one look better is called a straw man. It harms the ADR, because every reader notices that nothing was weighed here; what was already decided was justified.
  • “Leave it as it is” is an option. Often it is the cheapest. Whoever doesn't examine it changes things that don't need to be changed at all.
  • Options within the stack first. What the platform you already operate can do on its own costs less operations than a new tool. A new service needs a reason stronger than “it's familiar”.

For the comparison, you take the quality goals from the last lesson. A table with the affected goals as rows and the options as columns makes visible where the options really differ. For the import from K3, it looked like this:

Goal Leave it (import in the web process) Lock in the web process Job and worker via Service Bus
Q3 Booking despite an outage import and pages share a process same separate; an error in the import doesn't hit the pages
Q2 Response time import slows down the pages same pages untouched
Q4 Operations one program one program, plus a lock you have to understand three parts, each with monitoring
Q5 Cost no additional cost hardly any additional cost Service Bus and a second program
Duplicate import yes, once per replica no no, if the worker is idempotent

This table alone shows that “leave it” is out and that the second and third options differ mainly on Q2, Q3 and cost. The order of the goals says what weighs more.

Two more things belong in the preparation:

  • How hard is the decision to take back? A decision that can be reversed in a day needs less groundwork than one that data hangs on. With the easy ones, you decide quickly and see what happens.
  • What don't you know? If an option lives on an assumption that nobody has checked (“The emulator can do topics”), you check it beforehand with a small piece of code. An afternoon of proof of concept is cheaper than a month in the wrong direction.

Weighing means writing down what it costs

Every decision between options is a trade-off: You get something and give up something else. The list of pillars in the last lesson shows how they pull against each other. In an ADR, the trade-off belongs in a section of its own, and honestly:

  • Write what you give up, not just that there are disadvantages. “Three parts instead of one, plus the cost of Service Bus” is a trade-off. “Somewhat more effort” is not.
  • Write which goal pays for it. Then a later reader sees whether the situation has changed: If Q5 now ranks higher, the decision may turn out differently.
  • Don't hide consequences, not even unpleasant ones. Microsoft's guide says so explicitly. A consequence that only shows up in operations costs trust there.

A sentence that fits into almost every ADR and is often missing: What does somebody have to do now? A decision for Service Bus means: an idempotent receiver, dead-letter monitoring, roles for two identities. Those are consequences, and they belong in it.

The ADR

Microsoft's guide suggests the same parts for every ADR: the problem with its context, the options considered, the decision with its important trade-offs and the confidence with which it was made, and a status. It advises always using the same template, staying short and factual, and not inflating the ADR into a design manual: Whoever has more justification links to it.

The Kladde's template follows that and adds two sections: the security and privacy consequences that K4's house rules demand, and “Revisit when”:

markdown
# NNNN: Title of the decision

- Status: proposed
- Confidence: medium

## Context

What problem are we solving? Which requirements and quality goals (docs/quality-goals.md) apply, what is given?

## Options

### Option 1: …

How it works, what speaks for it, what speaks against it.

### Option 2: …

## Decision

We take option …, because …

## Trade-offs

What we give up with this decision, and which quality goal pays for it.

## Consequences

What gets easier, what gets harder and what is to be done next.

## Security and privacy consequences

Which data, which permissions, which new attack surface. “None” only with a reason.

## Revisit when

What tells us that the decision has to be made anew.

On the individual parts:

  • Title: the decision, not the question. “Public pages with static SSR” says what applies; “Rendering of the public pages” doesn't. The number has four digits and runs consecutively.
  • Status: proposed while the ADR is discussed in the pull request; accepted with the merge; rejected if the team decided against it (the ADR stays, because that is a decision too); superseded by with a link to the successor ADR.
  • Confidence: high, medium or low. A decision with low confidence is not bad. Sometimes you have to decide before you know everything. Then the field tells the later reader that a rethink is close at hand.
  • Context: what applied at the time of the decision, with the identifiers of the quality goals. Without context, you can't later judge whether the decision still fits.
  • Security and privacy consequences: Every architecture decision shifts data, permissions or attack surfaces. The section forces you to think about it while it's cheap. “None” is allowed, but only with a sentence on why. From module 4.8 on, you read these sections again for the threat model.
  • Revisit when: an event, not a date. “When more than one replica is running” can be observed; “in a year” gets forgotten.

What gets an ADR, and what doesn't? Microsoft's guide says: decisions that concern the structure of the system, important qualities or something hard to reverse. The test from lesson 4.1.1 helps: If more than the code would have to come along when you change it in a year, it is an ADR. The name of a class, the choice between two equivalent methods or a library you swap in an hour get a sentence in the pull request's description.

The date of a decision is in the Git history: The commit that sets the ADR to accepted is its date. Whoever prefers it in the text adds a line - Date: under the status.

The log is only appended to

The ADRs together form the architecture's log. One rule applies to this log that Microsoft's guide names explicitly: Accepted ADRs are not edited afterwards. If a decision changes, you write a new ADR that supersedes the old one, and link the two to each other. That way it stays visible what people thought back then, and when and why the direction changed.

In practice in the Kladde, that means:

  • The new ADR gets the next number and, under the status, the line - Supersedes: [0004](0004-run-import-in-web-process.md).
  • On the old ADR, exactly one line changes: the status, which now reads superseded by [0005](0005-import-via-job-and-service-bus.md). Context, options and reasoning stay as they were, even if they sound wrong today. They were right back then.
  • A typo may be corrected. A changed content may not.

In the review of a pull request that changes an accepted ADR, the first question is therefore: Does more than the status line change? If so, the change belongs in a new ADR.

A decision that is implemented in several steps (a simple solution now, a better one in half a year) gets, on Microsoft's advice, one ADR per step. That way each step can be superseded on its own.

Template, numbers and a script

The ADRs live as Markdown files in docs/adr/. The file name is the number and an English short name in lowercase letters and hyphens, like the lessons of this academy: 0002-use-static-ssr-for-public-pages.md. The text in it is English. The template sits next to them as docs/adr/template.md.

Looking up the next number by hand and copying the template is error-prone: Two pull requests take the same number, or the copy keeps the template's title. A small script does it reliably. Like every script in the Kladde, it lives in the repository, gives the same result when run twice and knows -WhatIf:

PowerShell
<#
.SYNOPSIS
Creates the next ADR from the template: docs/adr/NNNN-<Slug>.md.

.EXAMPLE
./scripts/New-Adr.ps1 -Title "Public pages with static SSR" -Slug use-static-ssr-for-public-pages
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [string] $Title,

    [Parameter(Mandatory)]
    [ValidatePattern('^[a-z0-9]+(-[a-z0-9]+)*$', Options = 'None')]
    [string] $Slug,

    [string] $Folder = (Join-Path $PSScriptRoot '..' 'docs' 'adr')
)

$ErrorActionPreference = 'Stop'
$Folder = (Resolve-Path -LiteralPath $Folder).Path
$records = @(Get-ChildItem -Path $Folder -Filter '*.md' -File | Where-Object Name -Match '^\d{4}-')

# A second run with the same slug creates nothing new.
$same = $records | Where-Object { $_.Name.Substring(5) -eq "$Slug.md" }
if ($same) {
    Write-Output "Already exists: $($same.Name)"
    return
}

$last = ($records | ForEach-Object { [int]$_.Name.Substring(0, 4) } | Measure-Object -Maximum).Maximum
$number = '{0:D4}' -f ([int]$last + 1)
$path = Join-Path $Folder "$number-$Slug.md"
$template = Get-Content -Path (Join-Path $Folder 'template.md') -Raw -Encoding utf8
$text = $template.Replace('# NNNN: Title of the decision', "# ${number}: $Title")

if ($PSCmdlet.ShouldProcess($path, 'Create ADR')) {
    Set-Content -Path $path -Value $text -NoNewline -Encoding utf8NoBOM
    Write-Output "Created: $number-$Slug.md"
}

A few places explained:

  • ValidatePattern rejects a short name with capital letters, underscores or umlauts before anything happens. Without Options = 'None' it would compare upper and lower case as equal, and Module-X would get through.
  • If there is already an ADR with the same short name, the script reports it and creates nothing. A second run therefore changes nothing.
  • -Filter '*.md' with a Where-Object after it instead of a pattern like [0-9] in the filter: The file system's filter knows only * and ? on Windows.
  • With no ADR in the folder, Maximum is empty, [int] turns it into 0, and the first ADR gets the number 0001.
  • ${number} in curly braces, because a colon follows right after it, which PowerShell would otherwise count as part of the variable's name.

A run with -WhatIf shows what would happen without creating anything. The ADR “Modules instead of microservices” doesn't come until module 4.3; here it only serves as a test:

PowerShell
./scripts/New-Adr.ps1 -Title "Modules instead of microservices" -Slug use-modules-not-microservices -WhatIf
What if: Performing the operation "Create ADR" on target "…\docs\adr\0006-use-modules-not-microservices.md".

Without -WhatIf, the script creates the file; a second run recognizes it. An invalid short name already fails at the check of the parameter:

Created: 0006-use-modules-not-microservices.md
Already exists: 0006-use-modules-not-microservices.md
New-Adr.ps1: Cannot validate argument on parameter 'Slug'. The argument "Module_X" does not match the "^[a-z0-9]+(-[a-z0-9]+)*$" pattern. Supply an argument that matches "^[a-z0-9]+(-[a-z0-9]+)*$" and try the command again.

The wording of PowerShell's messages depends on your system's language. Delete the test afterwards; it doesn't belong in the Kladde until module 4.3.

Checking the form in the build

A script helps with creating, but it doesn't prevent somebody from creating the file by hand and forgetting a section, or from a superseded ADR pointing to a successor that doesn't exist. A test checks these rules with every pull request. It lives in a test project of its own, tests/Kladde.Docs.Tests, because it doesn't test the Kladde's code, but its documents:

C#
using System.Text.RegularExpressions;

namespace Kladde.Docs.Tests;

// The rules of the ADR log in docs/adr, checked with every build.
public sealed partial class AdrLogTests
{
    private static readonly string[] Sections =
    [
        "## Context",
        "## Options",
        "## Decision",
        "## Trade-offs",
        "## Consequences",
        "## Security and privacy consequences",
        "## Revisit when",
    ];

    public static TheoryData<string> Records() => [.. RecordFiles().Select(file => Path.GetFileName(file))];

    [Fact]
    public void TheNumbersRunFromOneWithoutGaps()
    {
        var numbers = RecordFiles().Select(file => int.Parse(Path.GetFileName(file)[..4])).ToList();

        Assert.Equal(Enumerable.Range(1, numbers.Count), numbers);
    }

    [Theory]
    [MemberData(nameof(Records))]
    public void EveryRecordHasItsNumberStatusConfidenceAndSections(string name)
    {
        var lines = File.ReadAllLines(Path.Combine(AdrFolder(), name));

        Assert.StartsWith($"# {name[..4]}: ", lines[0]);
        Assert.Contains(lines, line => StatusLine().IsMatch(line));
        Assert.Contains(lines, line => ConfidenceLine().IsMatch(line));
        foreach (var section in Sections)
        {
            Assert.Contains(section, lines);
        }
    }

    [Fact]
    public void EveryReplacedRecordNamesItsSuccessorAndTheSuccessorNamesItBack()
    {
        foreach (var file in RecordFiles())
        {
            var status = File.ReadLines(file).Select(line => StatusLine().Match(line)).First(match => match.Success);
            if (!status.Groups["by"].Success)
            {
                continue;
            }

            var successor = Assert.Single(Directory.GetFiles(AdrFolder(), $"{status.Groups["by"].Value}-*.md"));
            var number = Path.GetFileName(file)[..4];
            Assert.Contains(File.ReadLines(successor), line => line.StartsWith($"- Supersedes: [{number}]", StringComparison.Ordinal));
        }
    }

    private static IEnumerable<string> RecordFiles() =>
        Directory.GetFiles(AdrFolder(), "*.md")
            .Where(file => RecordName().IsMatch(Path.GetFileName(file)))
            .Order(StringComparer.Ordinal);

    // From the test program's folder upward to the solution, then to docs/adr.
    private static string AdrFolder()
    {
        var folder = new DirectoryInfo(AppContext.BaseDirectory);
        while (folder is not null && !File.Exists(Path.Combine(folder.FullName, "Kladde.slnx")))
        {
            folder = folder.Parent;
        }

        return folder is null
            ? throw new DirectoryNotFoundException("Kladde.slnx not found.")
            : Path.Combine(folder.FullName, "docs", "adr");
    }

    [GeneratedRegex(@"^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$")]
    private static partial Regex RecordName();

    [GeneratedRegex(@"^- Status: (proposed|accepted|rejected|superseded by \[(?<by>\d{4})\]\(\d{4}-[a-z0-9-]+\.md\))$")]
    private static partial Regex StatusLine();

    [GeneratedRegex(@"^- Confidence: (high|medium|low)$")]
    private static partial Regex ConfidenceLine();
}

What the test checks:

  • TheNumbersRunFromOneWithoutGaps: The numbers run from 0001 with no gap and no duplicate number. Two pull requests that both create an ADR 0006 get caught at the second one at the latest.
  • EveryRecordHasItsNumberStatusConfidenceAndSections runs once per ADR (MemberData supplies the file names). A red ADR appears in the output with its name.
  • EveryReplacedRecordNamesItsSuccessorAndTheSuccessorNamesItBack: A superseded ADR points to a successor that exists, and that one points back.
  • AdrFolder looks for Kladde.slnx upward from the test program's folder. That works locally and in the pipeline, because the tests run there from bin below the repository.

What the test doesn't check: whether an accepted ADR was changed afterwards, and whether the content is good. The first is seen by the review in the change, the second is the point of the review.

With the five ADRs of this lesson, the test runs green, seven cases (shortened):

PowerShell
dotnet test --project tests/Kladde.Docs.Tests
Test run summary: Passed!
  total: 7
  failed: 0
  succeeded: 7
  skipped: 0
  duration: 2s 238ms

If an ADR lacks a section, the test names the ADR and the missing heading (shortened):

failed Kladde.Docs.Tests.AdrLogTests.EveryRecordHasItsNumberStatusConfidenceAndSections(name: "0006-use-modules-not-microservices.md") (13ms)
  Assert.Contains() Failure: Item not found in collection
  Collection: ["# 0006: Modules instead of microservices", "", "- Status: proposed", "- Confidence: medium", "", ···]
  Not found:  "## Security and privacy consequences"

The first ADR: recording ADRs

The first ADR of a log records that there is a log. That seems roundabout, but it is a decision in itself, with options and consequences, and it shows every new colleague first thing how things are done here:

markdown
# 0001: Record architecture decisions as ADRs

- Status: accepted
- Confidence: high

## Context

So far, the Kladde's decisions are scattered: some in `docs/decisions.md`, many only in descriptions of pull
requests, some only in people's heads. K4 adds decisions that are hard to take back (modules, events, tenants). Whoever
asks later why something is the way it is should find the answer in the repository, together with the paths that were rejected.

## Options

### Option 1: keep `docs/decisions.md` going

One file with paragraphs. Simple, but without a fixed form; a changed decision overwrites the old one, and its reason is lost.

### Option 2: one ADR per decision in `docs/adr/`

One Markdown file per decision following a template, numbered consecutively, only appended to. A new decision supersedes an old one
with a new ADR.

### Option 3: a wiki next to the repository

Convenient to edit, but not in the same pull request as the code and without review.

## Decision

We take option 2, because then the decision arises in the same pull request as the code, gets a review and is versioned with
the code.

## Trade-offs

Every important decision costs half an hour to an hour of writing. Small decisions get no ADR, but a sentence in
the pull request's description.

## Consequences

`docs/decisions.md` is migrated into ADRs and afterwards only points to `docs/adr/`. The template lives in
`docs/adr/template.md`, a test checks the form of all ADRs in the build.

## Security and privacy consequences

No new data or permissions. ADRs are as public as the repository: They never name keys, people's addresses or content
from operations.

## Revisit when

Nobody reads ADRs after the merge any more, for example because reviews never link them. Then the form is too heavy or the place is wrong.

Note the section “Security and privacy consequences”: Even here, where it is about documents, there is a consequence. An ADR in a public repository is public, so nothing from operations belongs in it.

Exercise 1

First bring the tool into the Kladde with which you'll create and check ADRs in the next two exercises:

  1. docs/adr/template.md with the template from above and docs/adr/0001-record-architecture-decisions.md.
  2. scripts/New-Adr.ps1.
  3. A test project tests/Kladde.Docs.Tests with AdrLogTests.cs, entered into Kladde.slnx so that the pipeline runs it too.

How to check your result: dotnet test in the root folder ends with exit code 0, and the output names Kladde.Docs.Tests. A run of New-Adr.ps1 with -WhatIf suggests 0002 as the next number as long as only 0001 is in the folder.

Solution 1

The new test project's project file looks like those of your other test projects. Since K3, the Kladde manages the versions of the packages centrally, so there is no version here:

XML
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <ImplicitUsings>enable</ImplicitUsings>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="xunit.v3.mtp-v2" />
  </ItemGroup>

</Project>

In Directory.Packages.props, the version is already there, because your other test projects use the same package:

XML
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.2" />

Into the project and into the solution:

PowerShell
dotnet solution add tests/Kladde.Docs.Tests/Kladde.Docs.Tests.csproj
dotnet test

With 0001 alone, the new test runs green, three cases. Two pitfalls when building it in:

  • The test doesn't find Kladde.slnx. Your solution file is named differently, say Kladde.sln. Adjust the name in AdrFolder.
  • Don't give the template a number. template.md doesn't begin with a number, which is why RecordName and the script skip it. Don't name it 0000-template.md: Then the test counts it as an ADR, the numbers start at 0000, and it turns red.

Exercise 2

Write two ADRs retroactively for decisions that were made in K2:

  1. 0002: The Kladde's public pages run with static SSR (noted in K2 as “public pages stay static” in docs/decisions.md).
  2. 0003: The business data lives in SQL Server or Azure SQL, not in a document database.

Create both with scripts/New-Adr.ps1. Each ADR has at least two real options, names the affected quality goals with their identifiers in the context, and has a section “Security and privacy consequences” that doesn't say “none”. The status is accepted, because the decisions have applied since K2.

How to check your result: Read only the section “Trade-offs”. Can you tell from it what the Kladde can't do because of this decision? If it lists only advantages, the trade-off is missing.

Solution 2

markdown
# 0002: Public pages with static SSR

- Status: accepted
- Confidence: high

## Context

The office's customers visit the public pages (home page, appointment booking) without signing in, often on the phone
over a mobile network. They have to work without script (accessibility), appear quickly and be readable by search engines.
Applies to Q2 (response time) and Q5 (cost): Many visits last only seconds.

## Options

### Option 1: static SSR

The server renders HTML, forms send a POST, after which the request is over. No open connection, no script needed.

### Option 2: interactive on the server

Every visiting person holds a SignalR connection to the server, which keeps the page's state in memory. Reacts without reloading,
but costs memory per visit and drops on a bad network.

### Option 3: interactive with WebAssembly

The .NET runtime is loaded into the browser. Smooth after loading, but a big first download on the phone beforehand.

## Decision

We take option 1 for all public pages, because it works without script, transfers the least on a weak network and
keeps nothing per visit on the server. Interactive islands stay reserved for the signed-in area.

## Trade-offs

Every step of the booking loads a new page; a selection that updates without reloading doesn't exist publicly. We give up
some convenience for accessibility and cost.

## Consequences

Booking runs in steps via GET and POST. Tests check the pages with `WebApplicationFactory` against the HTML, without a browser. Every
form needs an antiforgery token.

## Security and privacy consequences

There is no state in the browser except the antiforgery cookie. The public pages show free times, never the names of other customers.
The forms are the attack surface: Inputs are validated at the edge, booking is limited per address.

## Revisit when

booking needs an element that doesn't work sensibly without script, or when measurements show that visitors
abandon between the steps.
markdown
# 0003: SQL Server instead of a document database

- Status: accepted
- Confidence: high

## Context

The Kladde stores contacts, companies, appointments, bookings, tags and tenders. The data is interrelated (an appointment
belongs to a contact, tags many-to-many), and one rule needs a hard lock: Two bookings must not overlap,
even if they arrive at the same time. The team knows SQL and EF Core from K2. Locally, SQL Server runs in a container; in Azure there is a
free offer for Azure SQL Database.

## Options

### Option 1: SQL Server (local) and Azure SQL Database (cloud) with EF Core

Tables with foreign keys, transactions and unique constraints. Migrations change the schema.

### Option 2: Azure Cosmos DB as a document database

A contact with its appointments as one document. Flexible schema, worldwide distribution possible; relationships across documents and
the lock against overlaps we would have to build ourselves.

### Option 3: PostgreSQL (Azure Database for PostgreSQL)

Also relational and good, but a second tool next to the SQL Server that the team knows.

## Decision

We take option 1, because relationships and the lock against overlaps are exactly what a relational database with
transactions and unique constraints can do on its own, and because the team knows it.

## Trade-offs

Every change to the model needs a migration. The database grows upward (a bigger tier), not outward. Raw data without a fixed
form, like TED's notices, therefore doesn't live in tables but in Blob Storage.

## Consequences

`Kladde.Data` remains the only place that knows EF Core. Migrations run as a step of their own in the pipeline. Tests against real
dependencies use SQL Server in the container.

## Security and privacy consequences

The database holds personal data (names, addresses, phone numbers). Sign-in only with Entra ID, without a password. Backups
contain the same data: A deletion takes effect there only after its retention has expired.

## Revisit when

part of the data no longer has relationships and grows very fast (then a store of its own just for that part), or when a
context should keep its data as events instead of rows (module 4.4).

Two places where weaker solutions often get stuck: With 0002, “Blazor Server is outdated” is often given as a reason against option 2. That isn't true, and it wouldn't be a reason from the quality goals either. The reason is the memory per visit and the dropped connection on a bad network. With 0003, the sentence about the backups is often missing: Whoever puts personal data into a database puts it into its backups too.

Exercise 3

In K2, the import of the tenders ran as a BackgroundService in the web application; in K3 it became a scheduled job and a worker via Service Bus. Record both the way the log demands:

  1. 0004 describes the old decision from K2's point of view, with the knowledge of back then.
  2. 0005 describes the new decision from K3 and supersedes 0004.
  3. In the end, 0004 has the status superseded by [0005](…), and 0005 has the line - Supersedes: [0004](…).

How to check your result: dotnet test is green, including EveryReplacedRecordNamesItsSuccessorAndTheSuccessorNamesItBack. Do the counter-check: Delete the “Supersedes” line in 0005, and the test turns red. And read 0004 once more: Does it contain anything that couldn't have been known in K2? Then it belongs in 0005.

Solution 3

markdown
# 0004: Import of the tenders in the web process

- Status: superseded by [0005](0005-import-via-job-and-service-bus.md)
- Confidence: medium

## Context

In K2, the Kladde was to fetch the new tenders from the TED interface at night. There was a single delivered program,
the web application, with one replica.

## Options

### Option 1: a `BackgroundService` in `Kladde.Web`

A `PeriodicTimer` starts the import once at night, in the same process as the pages.

### Option 2: a program of its own for the import

A second program with its own deploy, its own monitoring and its own permissions.

## Decision

We take option 1, because a second program for a fetch at night costs more operations than it brings, as long as there is only one
replica.

## Trade-offs

The import shares memory and processor with the pages. A restart of the web application aborts a running import; it must
therefore be idempotent.

## Consequences

If more than one replica runs, the import runs several times too. That is the point at which this decision falls.

## Security and privacy consequences

The web application additionally needs access to the internet for the TED interface. Tenders are public data.

## Revisit when

the web application runs with more than one replica.
markdown
# 0005: Import as a scheduled job and worker via Service Bus

- Status: accepted
- Supersedes: [0004](0004-run-import-in-web-process.md)
- Confidence: medium

## Context

Since K3, the web application runs with two replicas, and the import from ADR 0004 therefore ran twice. A night's raw data
is large, and processing it slowed down the pages. Applies to Q3 (booking keeps working even if TED is down) and Q2 (response time).

## Options

### Option 1: stay with the `BackgroundService`, with a lock

A replica takes a lock before the import (such as a lease on a blob). Few new parts, but the import keeps slowing down the
pages.

### Option 2: a Container Apps job fetches, a worker processes

The job runs at night, puts the raw data into Blob Storage and sends one message per notice with the reference into the queue
`tender-import`. A worker reads them and writes to the database.

### Option 3: an Azure Function with a timer trigger

Similar to option 2, but one more hosting model next to Container Apps.

## Decision

We take option 2, because fetching and processing then run and scale independently of the pages, and because everything stays on the platform
we already operate.

## Trade-offs

Three parts instead of one (job, queue, worker), plus the cost of Service Bus. Messages arrive at least once: The worker
has to be idempotent. The format of the messages in `Kladde.Contracts` may only change in a backward-compatible way.

## Consequences

An error in the import no longer holds up the pages. A poison message ends up in the dead-letter queue, and an alert reports
it.

## Security and privacy consequences

Job and worker each have a managed identity of their own, only with the roles Azure Service Bus Data Sender and Data Receiver respectively.
Messages carry only references, no personal data.

## Revisit when

tenders should no longer arrive at night but continuously, or when another context wants to process the same notices
(then a topic instead of a queue).

Note: 0004 justifies its decision with “only one replica” and says under “Revisit when” when it falls. Exactly this event occurred in K3. That is how a good log works: The old ADR announces when it will be superseded, and the new one says in its context that this is how it turned out. 0004 is still not a mistake: For K2, it was the right decision.

The Kladde after this lesson

In docs/adr/ are template.md and the ADRs 0001 through 0005; scripts/New-Adr.ps1 creates new ones, tests/Kladde.Docs.Tests checks their form with every pull request. The order of the pull requests: first template, 0001, script and test (exercise 1); then 0002 and 0003; then 0004 and 0005 together, because they name each other.

Afterwards, migrate docs/decisions.md: Every entry that is an architecture decision gets an ADR. Some of it isn't architecture, such as a naming convention for branches; that belongs in the house rules or the README. In the end, the file contains only one sentence with the pointer to docs/adr/.

Also extend the pull request template from K2 by one item:

- [ ] hard-to-reverse decision? Then an ADR in docs/adr/, in the same pull request

From now on, every ADR arises in the same pull request as the code it justifies. The next ones come in module 4.2.

What you can do now

  • You phrase a decision as a question, collect real options, “leave it as it is” included, and compare them against the quality goals.
  • In every ADR, you write down what the decision costs and which goal pays for it.
  • You write ADRs with context, options, decision, trade-offs, consequences, security and privacy consequences, confidence and status.
  • You keep the log append-only and replace a decision with a new ADR, linked in both directions.
  • You create ADRs with an idempotent script and have their form checked by a test in the build.

Further reading