Eine Blazor-Anwendung beginnt meist mit den Ordnern, die die Vorlage anbietet: Pages, Components, Services, Models. Nach einem Jahr liegt in Models ein Typ Contact mit vierzig Eigenschaften, weil jede Seite ein Feld mehr brauchte, und keine Änderung lässt sich mehr machen, ohne drei Ordner und vier Seiten zu prüfen. Diese Lektion schneidet die Kladde nach Fachlichkeit, schreibt die Grenzen als Text auf und lässt einen Test darüber wachen, dass Text und Code zusammenpassen. Die Begriffe stammen aus dem strategischen Design von Domain-Driven Design; hier steht, was sie in einer Blazor-Anwendung mit mehreren Projekten bedeuten.

Wo die Kladde steht

Die Lektion setzt W8 voraus, besonders W8.2.3 (Kladde.UI), und aus K4 die Lektionen 4.2.4 (Bounded Contexts finden) und 4.2.5 (Context Maps); deren Heuristiken, Beziehungsmuster und den Anti-Corruption Layer wiederholt sie nicht. Neu ist die Lösung dieses Kurses, die ab hier zur Fallstudie gehört: Kladde.slnx mit den Modulen Kladde.Contacts, Kladde.Booking und Kladde.Tenders, je mit einem Projekt .Contracts, dazu Kladde.SharedKernel und dem Host Kladde.Web. Der Kurs arbeitet mit vier Kontexten: Contacts, Booking, Tenders und Identity. K4 schneidet sechs und nennt den Kontext für Zeiträume Termine (Appointments); dieser Kurs nennt ihn Booking, weil seine Sprache hier die Buchung eines Zeitraums für einen Kontakt ist, und lässt Vertrieb und Angebote beiseite, damit die Lösung klein genug bleibt, um sie ganz zu zeigen. Behalten Sie Ihre Namen und übertragen Sie die Schritte; weitere Kontexte aus K4 bekommen weitere Zeilen in der Karte.

Was ohne Schnitt passiert

Der Wunsch kommt aus dem Büro: Wer eine Buchung anlegt, soll sehen, ob der Kontakt eine Mailadresse hat. In einer Anwendung nach Technik sieht der Weg so aus:

src/Kladde.Web/
  Components/Pages/        Kontakte.razor, Buchung.razor, Ausschreibungen.razor
  Components/              KontaktKarte.razor, BuchungsListe.razor, FristAnzeige.razor
  Services/                KontaktService.cs, BuchungService.cs, TedImport.cs
  Models/                  Contact.cs, Booking.cs, Tender.cs

Buchung.razor braucht die Mailadresse, also kennt BuchungService jetzt Contact aus Models und liest Felder, die das Kontaktformular pflegt. Das nächste Feld, etwa „Tags“ für die Ausschreibungen, landet im selben Contact, denn dort ist es am bequemsten. Zwei Folgen:

  • Eine Änderung berührt viele Ordner. Wer die Pflichtfelder eines Kontakts ändert, prüft die Buchungsseite mit. Die Ordner sagen nicht, wer wen kennen darf.
  • Ein Wort, ein Typ, alle Wünsche. Contact wird zum Sammelbecken. Ein Typ kann nicht zugleich der heutige Stand einer Person und der Schnappschuss einer Buchung sein (K4 4.2.4).

Ein Schnitt nach Fachlichkeit fragt stattdessen, zu welchem Teil der Fachsprache etwas gehört: Seite, Regel und Speicher der Buchungen liegen zusammen, und wer von außen etwas braucht, geht über einen bereitgestellten Weg.

Vier Kontexte, vier Sprachen

Die vier Kontexte der Kladde und das Wort, an dem man sie auseinanderhält:

Kontext zentrales Wort was es dort heißt
Contacts Kontakt eine Person mit ihren heutigen Daten, die sich ändern dürfen
Booking Buchung ein bestätigter Zeitraum für genau einen Kontakt, danach eine Tatsache
Tenders Frist der Zeitpunkt, bis zu dem ein Angebot für eine Ausschreibung vorliegen muss
Identity Rolle Staff oder Admin, wie Entra ID sie vergibt

Das Wort „Rolle“ gibt es auch in den Kontakten: Dort ist es die Funktion der Person, etwa „Schulleitung“. Wer beides in einem Typ Role verwaltet, hat eines Tages eine Schulleitung mit Zugriff auf die Verwaltung. Das Wort „Kontakt“ ist in der Buchung etwas Kleineres: eine Nummer und ein Name zum Anzeigen. Im Code heißt das so:

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 (Auszug)
internal sealed record StoredBooking(
    BookingId Id, int ContactId, string ContactName, DateTimeOffset Start, int Minutes);

ContactSummary ist das, was die Kontakte nach außen geben. StoredBooking ist das, was eine Buchung von der Person behält: die Nummer, um später zum Kontakt zu springen, und den Namen, wie er bei der Buchung war. Keiner der beiden kennt die Felder des anderen; der Typ Contact mit Tags und Geburtstag kommt außerhalb der Kontakte nicht vor. Dass die Buchung den Namen als Schnappschuss behält, ist eine fachliche Entscheidung (K4 4.2.4).

Zuschnitt: nach Fachlichkeit, nicht nach Technik

Nach dem Schnitt sieht dieselbe Anwendung so aus:

src/
  Kladde.Contacts.Contracts/     was die Kontakte nach außen geben
  Kladde.Contacts/               Seiten, Regeln, Speicher der Kontakte
  Kladde.Booking.Contracts/      was die Buchung nach außen gibt
  Kladde.Booking/
    Features/RequestBooking/     Seite, Regel und Handler zum Buchen
    Features/ListBookings/       die Liste der Buchungen
  Kladde.Tenders.Contracts/
  Kladde.Tenders/
  Kladde.SharedKernel/           nur die Mechanik für Ereignisse
  Kladde.Web/                    Host: setzt alles zusammen, Anmeldung
  • Seiten gehören zu ihrem Kontext. RequestBookingPage.razor liegt in Kladde.Booking, nicht im Host. Eine Razor-Klassenbibliothek kann Seiten mit @page enthalten; der Host nimmt sie über AdditionalAssemblies in den Router auf (W11.1.2 zeigt die Verdrahtung).
  • Innerhalb eines Kontexts nach Funktion, nicht nach Art. Features/RequestBooking enthält Seite und Handler zusammen. K4 nennt das Vertical Slice (4.9.5); W11.1.2 nimmt es auf.
  • Werkzeug ist kein Kontext. Kladde.UI aus W8 hat keine Zeile in der Karte: Die Bibliothek kennt keine Buchung und keinen Kontakt. Fängt ein Baustein an, ContactSummary zu kennen, ist er ein Stück Kontakte, das sich in die Bibliothek verirrt hat.

Der Host Kladde.Web bleibt dünn: Anmeldung, Startseite, Verdrahtung, keine Fachlichkeit.

Die Beziehungen

K4 benennt die Muster; für die vier Kontexte dieses Kurses gelten diese:

  • Contacts → Booking: Kunde und Lieferant. Die Buchung braucht die Kontakte, die Kontakte liefern ContactSummary über IContactDirectory und kennen die Buchung nicht. Der Kunde darf sich etwas wünschen, aber kein Typ der Kontakte wandert in die Buchung.
  • TED → Tenders: Anti-Corruption Layer. TEDs Wörter bleiben hinter einem Übersetzer: TedTranslator verwandelt eine TedNotice (intern) in eine TenderSummary oder gibt null zurück. Warum das lohnt, steht in K4 4.2.5.
  • Entra ID → Identity: Konformist. Die Kladde übernimmt, was Entra ID über Anmeldung und Rollen sagt. Identity hat kein eigenes Modul, es lebt im Host.
  • Contacts und Tenders: getrennte Wege. Es fließt nichts; das ist eine Aussage und kein Versäumnis.

Ein Shared Kernel ist ein Stück Modell, das mehrere Kontexte gemeinsam pflegen. Kladde.SharedKernel enthält nur die Mechanik für Ereignisse (IEventPublisher, IEventHandler<T>) und kein Wort der Fachsprache: Jeder Typ, der dort einzieht, ist von nun an ein Wunsch, den kein Modul allein ändern darf.

Die Kontextlandkarte als Text, vom Test bewacht

Die Landkarte steht in docs/kontextlandkarte.md, damit sie mit dem Code durch jeden Pull Request geht. K4 führt die Beziehungen in einer Tabelle und einem Mermaid-Diagramm; hier kommt eine Tabelle dazu, die die Kontexte mit den Projekten verbindet (die Beziehungstabelle aus K4 bleibt daneben stehen und ist hier nicht abgedruckt). Dort greift der Test.

markdown
# Kontextlandkarte der Kladde

```mermaid
flowchart LR
    contacts(Contacts) -- "Kunde und Lieferant: ContactSummary" --> booking(Booking)
    ted[[TED]] -- "Anti-Corruption Layer" --> tenders(Tenders)
    entra[[Entra ID]] -- "Konformist" --> identity(Identity)
    contacts -.- tenders
```

## Kontexte

| Kontext | Projekt | Sprache in einem Satz |
| --- | --- | --- |
| Contacts | Kladde.Contacts | Ein Kontakt ist eine Person mit ihren heutigen Daten. |
| Booking | Kladde.Booking | Eine Buchung ist ein bestätigter Zeitraum für genau einen Kontakt. |
| Tenders | Kladde.Tenders | Eine Ausschreibung ist eine Bekanntmachung, die das Büro beobachtet. |
| Identity | Kladde.Web | Wer angemeldet ist, sagt Entra ID; der Host setzt es um. |

Der Test liest deshalb die Tabelle „Kontexte“ und vergleicht sie mit Kladde.slnx. Zwei Regeln genügen: Jede Zeile nennt ein Projekt der Lösung, und jedes Modul der Lösung hat eine Zeile (Module: alle Kladde.* außer Verträgen, Tests, Shared Kernel und 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 "## Kontexte": | Context | Project | Sentence |
    private static IReadOnlyList<(string Context, string Project)> MapRows()
    {
        string[] lines = File.ReadAllLines(Path.Combine(Repository.Root, "docs", "kontextlandkarte.md"));
        int start = Array.FindIndex(lines, l => l.StartsWith("## Kontexte", 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] != "Kontext" && !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/kontextlandkarte.md.");
        }
    }
}

Die Wurzel der Lösung findet Repository.Root (Lösung 2). Der Test prüft nur, ob Karte und Lösung zusammenpassen; ob ein Anti-Corruption Layer nötig ist, entscheidet ein Mensch.

Wie es in der Angular-Welt heißt

Manfred Steyer beschreibt denselben Gedanken in der Reihe „Modern Architectures with Angular“, deren erster Teil das strategische Design behandelt. Nach seiner Beschreibung zerlegt man das System gemeinsam mit Fachleuten, oft in einem Event Storming, in Subdomänen entlang der Geschäftsprozesse; jede entspricht einem Bounded Context, und eine Context Map zeigt die Abhängigkeiten. Innerhalb eines Kontexts sortiert er Module nach Art (Funktion, Oberflächenbausteine, Daten, Hilfen), und Kontexte sprechen nur ihre eigenen Module und einen gemeinsamen Bereich an. Das Werkzeug Sheriff prüft das mit Etiketten, die es Ordnern zuweist; jedes Modul legt seine öffentliche Schnittstelle in einer Datei fest. Unsere Entsprechung: Das Projekt ist das Etikett, das Projekt .Contracts die öffentliche Schnittstelle, und geprüft wird über Projektverweise, internal und die Tests aus W11.2.2. Die Schichten innerhalb eines Kontexts übernimmt dieser Kurs nicht. Das kostenlose Buch „Enterprise Angular“ führt „Strategic Domain-Driven Design“ als eigenes Kapitel; was darin steht, ist hier nicht gelesen und bleibt offen.

Der Schnitt als Entscheidung

Dass die Kladde nach Kontexten geschnitten ist, kostet etwas: mehr Projekte, Verträge, die jemand pflegt, ein Host, der alles zusammensetzt. Es gehört in ein ADR, wie K4 es in Lektion 4.1.3 zeigt. Die Übung 3 schreibt es.

Übung 1: Erst denken

Im Büro fallen diese Sätze, und jeder enthält ein Wort, das nicht überall dasselbe heißt. Entscheiden Sie je Satz, in welchen Kontexten das Wort vorkommt und ob es dort dasselbe meint. Schreiben Sie keinen Code.

  1. „Ole ist Admin und darf Ausschreibungen löschen.“
  2. „Frau Kranich ist die Schulleitung und bekommt die Rechnung.“
  3. „Der Termin am Dienstag fällt aus.“
  4. „Die Frist für die Tablet-Ausschreibung läuft ab.“

So prüfen Sie: Zu jedem Satz haben Sie ein Wort, die betroffenen Kontexte und einen Satz zum Unterschied. Welcher der vier Sätze braucht kein zweites Modell?

Lösung 1

  1. „Admin“ und „Rolle“: Identity. Der Satz meint die Rolle aus Entra ID. Die Kontakte kennen „Schulleitung“ als Funktion der Person; es ist dasselbe Wort, aber nicht dieselbe Sache, und beide dürfen nie in einem Typ Role stehen.
  2. „Schulleitung“ ist die Funktion in den Kontakten. Die Rechnung gehört in keinen der vier Kontexte; K4 schneidet sie in den Vertrieb. Wer nur diese vier Kontexte hat, schreibt sie auf die Liste der Fragen, statt einen Kontext dafür zu erfinden.
  3. „Termin“: Für das Büro ist es die Buchung, also Booking. Die Absage ist ein Zustand der Buchung, kein eigener Kontext.
  4. „Frist“ gehört nur zu Tenders. Das Wort kommt in der Buchung nicht vor, dort heißt es „Dauer“ oder „Ende“. Dieser Satz braucht kein zweites Modell.

Wer Satz 4 in „Termin“ verlegt, weil beides ein Datum ist, hat nach Technik geschnitten: Beide sind ein DateTimeOffset, aber eine Frist ist ein Ablaufdatum, ein Termin ein Zeitraum mit Anfang und Ende.

Übung 2: Die Karte schreiben und prüfen lassen

Bringen Sie Ihre Lösung in die Form dieser Lektion.

  1. Legen Sie die Projekte an, soweit Ihre Kladde sie noch nicht hat. Für ein Modul mit Seiten und seinen Vertrag, hier 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. Verschieben Sie Seiten und Dienste Ihrer Kladde in das Projekt ihres Kontexts. Typen, die zwei Kontexte meinen, spalten Sie in zwei Typen.
  2. Schreiben Sie docs/kontextlandkarte.md wie oben, mit Ihren Kontexten.
  3. Legen Sie tests/Kladde.Architecture.Tests an (xUnit und Shouldly wie in K2, IsPackable=false), nehmen Sie es in Kladde.slnx auf und schreiben Sie die beiden Tests.

So prüfen Sie: dotnet test tests/Kladde.Architecture.Tests endet mit dem Exit-Code 0 (lesen Sie $LASTEXITCODE). Löschen Sie dann die Zeile von Tenders in der Tabelle „Kontexte“: Der Lauf muss rot werden und Kladde.Tenders is a module without a context in docs/kontextlandkarte.md. nennen. Stellen Sie die Zeile wieder her. Ändern Sie danach in einer Zeile das Projekt, etwa zu Kladde.Buchung: Sie bekommen die Meldung, dass es nicht in Kladde.slnx steht.

Lösung 2

Repository.cs im Testprojekt. Es geht vom Ausgabeordner des Tests so lange nach oben, bis es Kladde.slnx findet, und bricht mit einer Meldung ab, wenn es sie nicht gibt:

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.");
    }
}

Das Testprojekt braucht dafür keinen Verweis auf die Module; die Tests lesen nur Dateien. Ein typischer Fehler ist ein Tabellenkopf, der nicht „Kontext“ heißt: Dann zählt der Test die Kopfzeile als Kontext und meldet ein Projekt Projekt, das es nicht gibt.

Übung 3: Das ADR

Schreiben Sie docs/adr/0007-kontexte-als-module.md nach dem Muster aus K4 (Lektion 4.1.3): Kontext, Optionen, Entscheidung, Abwägungen, Folgen, Folgen für Sicherheit und Datenschutz, Überprüfen, wenn. Drei Optionen: die Ordner der Vorlage behalten, ein Projekt je Kontext, ein Repository je Kontext. Nummer und Titel passen Sie an Ihr Log an.

So prüfen Sie: Das ADR nennt bei jeder Option, was sie kostet, und steht in dem Skript zum Prüfen der ADRs aus K4, falls Sie es haben, ohne Fehler. Unter „Überprüfen, wenn“ steht eine Bedingung, an der man sie erkennt, nicht „bei Bedarf“.

Lösung 3

Gekürzt; „Abwägungen“ und „Folgen für Sicherheit und Datenschutz“ aus K4 gehören in Ihr ADR. Die Bedingungen sind Beispiele, keine Messwerte:

markdown
# 0007: Ein Projekt je Kontext, Seiten gehören dem Kontext

Status: angenommen

## Kontext
Die Kladde hat vier Kontexte (`docs/kontextlandkarte.md`). In den Ordnern der Vorlage greifen Buchung
und Ausschreibungen auf denselben Typ `Contact` zu.

## Optionen
1. Ordner der Vorlage behalten: kein Umbau, die Grenzen bleiben eine Absprache.
2. Ein Projekt je Kontext (mit Seiten) und ein Projekt `.Contracts`: der Compiler verweigert, was kein
   Verweis erlaubt; Kosten: mehr Projekte und Verträge, die jemand pflegt.
3. Ein Repository je Kontext: stärkste Trennung; Kosten: Versionsabstimmung, mehrere Pull Requests.

## Entscheidung
Option 2. Option 3 kostet für ein Team dieser Größe mehr Abstimmung, als sie an Trennung bringt.

## Folgen
Andere Kontexte kennen nur den Vertrag. Ein Test vergleicht die Karte mit der Lösung. Name und
Mailadresse stehen als Schnappschuss auch in der Buchung (Datenschutz: K4, Modul 4.2.6).

## Überprüfen, wenn
Zwei Kontexte in jedem zweiten Pull Request gemeinsam geändert werden, oder ein Team nur einen
Kontext pflegt und eigene Releases braucht.

Die Bedingung „Überprüfen, wenn“ ist der Teil, der dem ADR Leben gibt: Ändern sich Kontexte immer gemeinsam, war der Schnitt falsch gezogen, und das ADR sagt, dass man dann nachsehen soll.

Stand der Prüfung. Gebaut und getestet wurde eine Wegwerf-Lösung außerhalb des Repositorys mit dem .NET-SDK 10.0.400 auf net10.0: Kladde.slnx mit den Modulen Kladde.Contacts, Kladde.Booking, Kladde.Tenders (je mit .Contracts), Kladde.SharedKernel, dem Host Kladde.Web und drei Testprojekten. dotnet build Kladde.slnx -m:1 --no-incremental endete mit dem Exit-Code 0 und null Warnungen, dotnet test Kladde.slnx ebenfalls mit 0; Kladde.Architecture.Tests meldete Bestanden! : Fehler: 0, erfolgreich: 7, übersprungen: 0, gesamt: 7 (davon zwei Tests dieser Lektion). Ohne die Zeile von Tenders in docs/kontextlandkarte.md meldete der Lauf Kladde.Tenders is a module without a context in docs/kontextlandkarte.md.; mit Kladde.Buchung statt Kladde.Booking meldete er The context Booking names Kladde.Buchung, which is not in Kladde.slnx. Nicht gezeichnet wurde das Mermaid-Diagramm, nicht Teil der Wegwerf-Lösung war Kladde.UI aus W8, und das ADR wurde nicht mit dem Skript aus K4 geprüft. Die Quellen zu Angular sind gelesen, nicht ausprobiert.

Was Sie jetzt können

  • Sie erklären, warum die Ordner Pages, Components, Services und Models keine Grenzen sind, und zeigen es an einer Änderung, die drei Ordner berührt.
  • Sie schreiben ein Wort, das zwei Kontexte meinen, als zwei Typen und verbinden sie nur über Id und Vertrag.
  • Sie legen Seiten, Regeln und Speicher eines Kontexts in ein Projekt und ordnen Werkzeug-Bibliotheken keinem Kontext zu.
  • Sie führen die Kontextlandkarte als Text und lassen einen Test prüfen, dass Karte und Lösung zusammenpassen.
  • Sie halten den Schnitt in einem ADR fest, mit einer Bedingung, die ihn überprüfbar macht.

Die Kladde: Sie hat vier Kontexte, drei Module mit Verträgen und eine Karte, die beim Bauen geprüft wird. Als Nächstes bekommt jedes Modul seine Form: was öffentlich ist, wie es sich registriert und wie Module sich in einem Prozess unterhalten.

Weiterlesen

Alle Quellen unten wurden am Schreibtag gelesen.