A Blazor application usually starts with the folders that the template offers: Pages, Components, Services, Models. After a year, Models holds a type Contact with forty properties, because every page needed one more field, and no change can be made anymore without checking three folders and four pages. This lesson cuts the Kladde along the business domain, writes the boundaries down as text and has a test make sure that text and code fit together. The terms come from the strategic design of Domain-Driven Design; here you read what they mean in a Blazor application with several projects.

Where the Kladde stands

The lesson presupposes W8, especially W8.2.3 (Kladde.UI), and from K4 the lessons 4.2.4 (Finding bounded contexts) and 4.2.5 (Context maps); it does not repeat their heuristics, relationship patterns or the anti-corruption layer. What is new is the solution of this course, which is part of the case study from here on: Kladde.slnx with the modules Kladde.Contacts, Kladde.Booking and Kladde.Tenders, each with a project .Contracts, plus Kladde.SharedKernel and the host Kladde.Web. The course works with four contexts: Contacts, Booking, Tenders and Identity. K4 cuts six and calls the context for time slots Appointments; this course calls it Booking, because its language here is the booking of a time slot for a contact, and it leaves out sales and quotes so that the solution stays small enough to show in full. Keep your own names and carry the steps over; further contexts from K4 get further rows in the map.

What happens without a cut

The request comes from the office: whoever creates a booking should see whether the contact has an email address. In an application organized by technology, the path looks like this:

src/Kladde.Web/
  Components/Pages/        Contacts.razor, Booking.razor, Tenders.razor
  Components/              ContactCard.razor, BookingList.razor, DeadlineDisplay.razor
  Services/                ContactService.cs, BookingService.cs, TedImport.cs
  Models/                  Contact.cs, Booking.cs, Tender.cs

Booking.razor needs the email address, so BookingService now knows Contact from Models and reads fields that the contact form maintains. The next field, say "Tags" for the tenders, ends up in the same Contact, because that is the most convenient place. Two consequences:

  • One change touches many folders. Whoever changes the required fields of a contact also checks the booking page. The folders do not say who may know whom.
  • One word, one type, every request. Contact becomes a catch-all. A type cannot be both a person's current state and the snapshot of a booking (K4 4.2.4).

A cut along the business domain asks instead which part of the business language something belongs to: page, rule and storage of the bookings lie together, and whoever needs something from outside goes through a path that is provided for it.

Four contexts, four languages

The four contexts of the Kladde and the word that tells them apart:

Context central word what it means there
Contacts contact a person with their current data, which may change
Booking booking a confirmed time slot for exactly one contact, a fact afterward
Tenders deadline the point in time by which an offer for a tender must be in
Identity role Staff or Admin, as Entra ID assigns them

The word "role" also exists in Contacts: there it is the person's function, say "principal". Whoever manages both in one type Role has, one day, a principal with access to the administration. The word "contact" is something smaller in Booking: a number and a name to display. In code, that looks like this:

C#
// src/Kladde.Contacts.Contracts/Contacts.cs
namespace Kladde.Contacts.Contracts;

public readonly record struct ContactId(int Value);

public sealed record ContactSummary(ContactId Id, string Name, string? Email);
C#
// src/Kladde.Booking/BookingStore.cs (excerpt)
internal sealed record StoredBooking(
    BookingId Id, int ContactId, string ContactName, DateTimeOffset Start, int Minutes);

ContactSummary is what Contacts gives to the outside. StoredBooking is what a booking keeps of the person: the number, to jump to the contact later, and the name as it was at the time of booking. Neither knows the fields of the other; the type Contact with tags and birthday does not occur outside Contacts. That the booking keeps the name as a snapshot is a business decision (K4 4.2.4).

The cut: by business domain, not by technology

After the cut, the same application looks like this:

src/
  Kladde.Contacts.Contracts/     what Contacts gives to the outside
  Kladde.Contacts/               pages, rules, storage of Contacts
  Kladde.Booking.Contracts/      what Booking gives to the outside
  Kladde.Booking/
    Features/RequestBooking/     page, rule and handler for booking
    Features/ListBookings/       the list of bookings
  Kladde.Tenders.Contracts/
  Kladde.Tenders/
  Kladde.SharedKernel/           only the mechanics for events
  Kladde.Web/                    host: puts everything together, sign-in
  • Pages belong to their context. RequestBookingPage.razor lies in Kladde.Booking, not in the host. A Razor class library can contain pages with @page; the host adds them to the router through AdditionalAssemblies (W11.1.2 shows the wiring).
  • Inside a context, by function, not by kind. Features/RequestBooking holds page and handler together. K4 calls this a vertical slice (4.9.5); W11.1.2 picks it up.
  • A tool is not a context. Kladde.UI from W8 has no row in the map: the library knows no booking and no contact. If a building block starts to know ContactSummary, it is a piece of Contacts that has strayed into the library.

The host Kladde.Web stays thin: sign-in, start page, wiring, no business logic.

The relationships

K4 names the patterns; for the four contexts of this course, these apply:

  • Contacts → Booking: customer and supplier. Booking needs Contacts, Contacts supplies ContactSummary through IContactDirectory and does not know Booking. The customer may ask for something, but no type of Contacts migrates into Booking.
  • TED → Tenders: anti-corruption layer. TED's words stay behind a translator: TedTranslator turns a TedNotice (internal) into a TenderSummary or returns null. Why that pays off is in K4 4.2.5.
  • Entra ID → Identity: conformist. The Kladde adopts what Entra ID says about sign-in and roles. Identity has no module of its own, it lives in the host.
  • Contacts and Tenders: separate ways. Nothing flows; that is a statement and not an omission.

A shared kernel is a piece of model that several contexts maintain together. Kladde.SharedKernel contains only the mechanics for events (IEventPublisher, IEventHandler<T>) and not a word of the business language: every type that moves in there is from now on a wish that no module may change alone.

The context map as text, guarded by a test

The map lives in docs/context-map.md, so that it goes through every pull request together with the code. K4 keeps the relationships in a table and a Mermaid diagram; here a table is added that connects the contexts with the projects (the relationship table from K4 stays next to it and is not printed here). That is where the test takes hold.

markdown
# Context map of the Kladde

```mermaid
flowchart LR
    contacts(Contacts) -- "customer and supplier: ContactSummary" --> booking(Booking)
    ted[[TED]] -- "Anti-corruption layer" --> tenders(Tenders)
    entra[[Entra ID]] -- "Conformist" --> identity(Identity)
    contacts -.- tenders
```

## Contexts

| Context | Project | Language in one sentence |
| --- | --- | --- |
| Contacts | Kladde.Contacts | A contact is a person with their current data. |
| Booking | Kladde.Booking | A booking is a confirmed time slot for exactly one contact. |
| Tenders | Kladde.Tenders | A tender is a notice that the office watches. |
| Identity | Kladde.Web | Who is signed in is said by Entra ID; the host puts it into effect. |

The test therefore reads the table "Contexts" and compares it with Kladde.slnx. Two rules suffice: every row names a project of the solution, and every module of the solution has a row (modules: all Kladde.* except contracts, tests, shared kernel and host).

C#
// tests/Kladde.Architecture.Tests/ContextMapTests.cs
using System.Xml.Linq;
using Shouldly;

namespace Kladde.Architecture.Tests;

public class ContextMapTests
{
    // Rows of the table under "## Contexts": | Context | Project | Sentence |
    private static IReadOnlyList<(string Context, string Project)> MapRows()
    {
        string[] lines = File.ReadAllLines(Path.Combine(Repository.Root, "docs", "context-map.md"));
        int start = Array.FindIndex(lines, l => l.StartsWith("## Contexts", StringComparison.Ordinal));
        return lines
            .Skip(start + 1)
            .TakeWhile(l => !l.StartsWith("## ", StringComparison.Ordinal))
            .Where(l => l.StartsWith('|'))
            .Select(l => l.Trim('|').Split('|').Select(c => c.Trim()).ToArray())
            .Where(cells => cells.Length >= 2 && cells[0] != "Context" && !cells[0].StartsWith('-'))
            .Select(cells => (cells[0], cells[1]))
            .ToList();
    }

    private static IReadOnlyList<string> SolutionProjects() =>
        XDocument.Load(Path.Combine(Repository.Root, "Kladde.slnx"))
            .Descendants("Project")
            .Select(p => Path.GetFileNameWithoutExtension((string)p.Attribute("Path")!))
            .ToList();

    [Fact]
    public void EveryRowOfTheMapPointsToAProjectInTheSolution()
    {
        IReadOnlyList<string> projects = SolutionProjects();

        foreach ((string context, string project) in MapRows())
        {
            projects.ShouldContain(project, $"The context {context} names {project}, which is not in Kladde.slnx.");
        }
    }

    [Fact]
    public void EveryModuleOfTheSolutionHasARowInTheMap()
    {
        var mapped = MapRows().Select(r => r.Project).ToHashSet();
        string[] modules = SolutionProjects()
            .Where(p => p.StartsWith("Kladde.", StringComparison.Ordinal))
            .Where(p => !p.EndsWith(".Contracts", StringComparison.Ordinal) && !p.EndsWith(".Tests", StringComparison.Ordinal))
            .Where(p => p is not ("Kladde.SharedKernel" or "Kladde.Web"))
            .ToArray();

        modules.ShouldNotBeEmpty();
        foreach (string module in modules)
        {
            mapped.ShouldContain(module, $"{module} is a module without a context in docs/context-map.md.");
        }
    }
}

Repository.Root finds the root of the solution (solution 2). The test only checks whether map and solution fit together; whether an anti-corruption layer is needed is decided by a person.

What it is called in the Angular world

Manfred Steyer describes the same idea in the series "Modern Architectures with Angular", whose first part covers strategic design. By his description, you break the system down together with domain experts, often in an event storming, into subdomains along the business processes; each corresponds to a bounded context, and a context map shows the dependencies. Inside a context, he sorts modules by kind (feature, UI building blocks, data, utilities), and contexts talk only to their own modules and a shared area. The tool Sheriff checks this with tags that it assigns to folders; each module defines its public interface in one file. Our counterpart: the project is the tag, the project .Contracts is the public interface, and checking happens through project references, internal and the tests from W11.2.2. This course does not adopt the layers inside a context. The free book "Enterprise Angular" lists "Strategic Domain-Driven Design" as a chapter of its own; what is in it has not been read here and stays open.

The cut as a decision

That the Kladde is cut by contexts costs something: more projects, contracts that someone maintains, a host that puts everything together. It belongs in an ADR, as K4 shows it in lesson 4.1.3. Exercise 3 writes it.

Exercise 1: Think first

In the office, these sentences are said, and each contains a word that does not mean the same everywhere. For each sentence, decide in which contexts the word occurs and whether it means the same thing there. Do not write code.

  1. "Ole is an admin and may delete tenders."
  2. "Ms. Kranich is the principal and gets the invoice."
  3. "Tuesday's appointment is canceled."
  4. "The deadline for the tablet tender is about to expire."

How to check: For each sentence, you have one word, the contexts concerned and one sentence about the difference. Which of the four sentences needs no second model?

Solution 1

  1. "Admin" and "role": Identity. The sentence means the role from Entra ID. Contacts know "principal" as a function of the person; it is the same word but not the same thing, and the two must never end up in one type Role.
  2. "Principal" is the function in Contacts. The invoice belongs in none of the four contexts; K4 cuts it into sales. Whoever has only these four contexts writes it on the list of open questions instead of inventing a context for it.
  3. "Appointment": for the office it is the booking, so Booking. The cancellation is a state of the booking, not a context of its own.
  4. "Deadline" belongs to Tenders only. The word does not occur in Booking, where it is called "duration" or "end". This sentence needs no second model.

Whoever moves sentence 4 into "appointment" because both are a date has cut by technology: both are a DateTimeOffset, but a deadline is an expiry date and an appointment is a time slot with a start and an end.

Exercise 2: Write the map and have it checked

Bring your solution into the shape of this lesson.

  1. Create the projects, as far as your Kladde does not have them yet. For a module with pages and its contract, here Booking:
PowerShell
dotnet new classlib -o src/Kladde.Booking.Contracts
dotnet new razorclasslib -o src/Kladde.Booking
dotnet reference add src/Kladde.Booking.Contracts/Kladde.Booking.Contracts.csproj --project src/Kladde.Booking/Kladde.Booking.csproj
dotnet sln Kladde.slnx add src/Kladde.Booking.Contracts/Kladde.Booking.Contracts.csproj src/Kladde.Booking/Kladde.Booking.csproj
  1. Move the pages and services of your Kladde into the project of their context. Types that mean two contexts, you split into two types.
  2. Write docs/context-map.md as above, with your contexts.
  3. Create tests/Kladde.Architecture.Tests (xUnit and Shouldly as in K2, IsPackable=false), add it to Kladde.slnx and write the two tests.

How to check: dotnet test tests/Kladde.Architecture.Tests ends with exit code 0 (read $LASTEXITCODE). Then delete the row of Tenders in the table "Contexts": the run must turn red and name Kladde.Tenders is a module without a context in docs/context-map.md. Restore the row. Afterward, change the project in one row, say to Kladde.Bookings: you get the message that it is not in Kladde.slnx.

Solution 2

Repository.cs in the test project. It goes up from the test's output folder until it finds Kladde.slnx, and stops with a message if there is none:

C#
namespace Kladde.Architecture.Tests;

internal static class Repository
{
    public static string Root { get; } = FindRoot();

    private static string FindRoot()
    {
        var directory = new DirectoryInfo(AppContext.BaseDirectory);
        while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Kladde.slnx")))
        {
            directory = directory.Parent;
        }

        return directory?.FullName ?? throw new InvalidOperationException("Kladde.slnx not found above the test output.");
    }
}

The test project needs no reference to the modules for this; the tests only read files. A typical mistake is a table header that is not called "Context": then the test counts the header row as a context and reports a project Project that does not exist.

Exercise 3: The ADR

Write docs/adr/0007-contexts-as-modules.md following the pattern from K4 (lesson 4.1.3): context, options, decision, trade-offs, consequences, security and privacy consequences, revisit when. Three options: keep the template's folders, one project per context, one repository per context. You adapt the number and title to your log.

How to check: The ADR names what each option costs, and it passes the script for checking the ADRs from K4, if you have it, without errors. Under "Revisit when" there is a condition by which you can recognize it, not "as needed".

Solution 3

Shortened; "Trade-offs" and "Security and privacy consequences" from K4 belong in your ADR. The conditions are examples, not measured values:

markdown
# 0007: One project per context, pages belong to the context

Status: accepted

## Context
The Kladde has four contexts (`docs/context-map.md`). In the template's folders, booking
and tenders access the same type `Contact`.

## Options
1. Keep the template's folders: no rebuild, the boundaries stay an agreement.
2. One project per context (with pages) and a project `.Contracts`: the compiler refuses what no
   reference allows; cost: more projects and contracts that someone maintains.
3. One repository per context: strongest separation; cost: version coordination, several pull requests.

## Decision
Option 2. For a team of this size, option 3 costs more coordination than it gains in separation.

## Consequences
Other contexts know only the contract. A test compares the map with the solution. Name and
email address are also stored as a snapshot in the booking (privacy: K4, module 4.2.6).

## Revisit when
Two contexts are changed together in every second pull request, or a team maintains only one
context and needs releases of its own.

The condition "Revisit when" is the part that gives the ADR life: if contexts always change together, the cut was drawn wrong, and the ADR says that this is the moment to take another look.

Status of verification. A throwaway solution outside the repository was built and tested with the .NET SDK 10.0.400 on net10.0: Kladde.slnx with the modules Kladde.Contacts, Kladde.Booking, Kladde.Tenders (each with .Contracts), Kladde.SharedKernel, the host Kladde.Web and three test projects. dotnet build Kladde.slnx -m:1 --no-incremental ended with exit code 0 and zero warnings, dotnet test Kladde.slnx also with 0; Kladde.Architecture.Tests reported Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7 (two of them tests of this lesson). Without the row of Tenders in docs/context-map.md, the run reported Kladde.Tenders is a module without a context in docs/context-map.md.; with the wrong project name Kladde.Buchung instead of Kladde.Booking, it reported The context Booking names Kladde.Buchung, which is not in Kladde.slnx. Not drawn was the Mermaid diagram, Kladde.UI from W8 was not part of the throwaway solution, and the ADR was not checked with the script from K4. The sources on Angular have been read, not tried out.

What you can do now

  • You explain why the folders Pages, Components, Services and Models are not boundaries, and show it with a change that touches three folders.
  • You write a word that two contexts mean as two types and connect them only through an id and a contract.
  • You put the pages, rules and storage of a context into one project and assign tool libraries to no context.
  • You keep the context map as text and have a test check that map and solution fit together.
  • You record the cut in an ADR, with a condition that makes it reviewable.

The Kladde: it has four contexts, three modules with contracts and a map that is checked at build time. Next, each module gets its shape: what is public, how it registers itself and how modules talk to each other in one process.

Further reading

All sources below were read on the day of writing.