Die Kontextlandkarte aus der vorigen Lektion steht auf Papier und wird vom Test mit der Lösung abgeglichen. Was fehlt, ist die Form eines Moduls im Code: Was darf ein anderes Modul sehen, wie meldet sich ein Modul beim Host an, wem gehören die Seiten, und wie sprechen zwei Module miteinander, ohne sich zu kennen? Diese Lektion baut die Antworten an der Kladde, und jede wird von etwas geprüft, vom Compiler oder von einem Test. Dass ein Modul ein Projekt mit einem Vertrag ist, kennen Sie aus K4; neu ist, was das für eine Blazor-Anwendung heißt: Die Seiten wandern mit in das Modul.

Wo die Kladde steht

Die Lektion setzt W11.1.1 voraus (vier Kontexte, docs/kontextlandkarte.md, der Test, der Karte und Lösung vergleicht) und aus K4 die Lektion 4.3.6 „Der modulare Monolith“. Dort steht, warum Module vor Microservices kommen, wie ein Schema je Modul die Daten trennt, was InternalsVisibleTo ist und wie ein Architekturtest die Richtung der Projektverweise prüft; das wiederholt diese Lektion nicht. Zwei Dinge sind hier bewusst anders als in K4, und beide gelten für den Rest des Kurses:

  • Ein Modul ist klein: ein Projekt und sein Vertrag. K4 schneidet jedes Modul in .Domain, .Application, .Contracts und .Sql. Die Lösung dieses Kurses soll in einer Lektion ganz zu zeigen sein, deshalb liegen die Schichten als Ordner in einem Projekt. Wer mehr Trennung braucht, spaltet es später nach K4; die Außengrenze ändert sich dadurch nicht.
  • Die Seiten gehören dem Modul. In K4 bleiben Seiten im Host, in einem Ordner je Modul. Hier ist jedes Modul eine Razor-Klassenbibliothek, und RequestBookingPage.razor liegt in Kladde.Booking. Der Host hat keine Seite der Fachlichkeit mehr.

Die Lösung ist die aus W11.1.1; neu sind Registrierungen, Seiten, Ereignisse und Testprojekte. Kladde.UI aus W8 ist nicht dabei.

Ein Modul, zwei Projekte

src/Kladde.Contacts.Contracts/   Contacts.cs         ContactId, ContactSummary, IContactDirectory
src/Kladde.Contacts/             ContactsModule.cs   die Anmeldung beim Host
                                 ContactStore.cs     die Daten des Moduls (intern)
                                 Features/ListContacts/   eine Seite

Die Projektdateien sind kurz, und TargetFramework fehlt, weil es in einer gemeinsamen Datei steht (W11.2.1). Der Vertrag ist ein leeres <Project Sdk="Microsoft.NET.Sdk">. Das Modul ist eine Razor-Klassenbibliothek, weil es Seiten enthält:

XML
<!-- src/Kladde.Contacts/Kladde.Contacts.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Razor">
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Kladde.Contacts.Contracts\Kladde.Contacts.Contracts.csproj" />
  </ItemGroup>
</Project>

Den Vertrag schreibt das Modul selbst: Typen und Schnittstellen, keine Logik.

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);

public interface IContactDirectory
{
    Task<ContactSummary?> FindAsync(ContactId id, CancellationToken cancellationToken);

    Task<IReadOnlyList<ContactSummary>> ListAsync(CancellationToken cancellationToken);
}

Kladde.Booking.Contracts verweist auf Kladde.Contacts.Contracts und auf sonst nichts: BookingRequest trägt eine ContactId. Die Buchung weiß, wie man einen Kontakt benennt, aber nicht, was ein Kontakt ist; das ist die Kunde-und-Lieferant-Beziehung der Karte, im Code gelesen.

internal als Standard

Alles, was nicht im Vertrag steht, ist internal. In Kladde.Booking sind der Speicher und die Regel für Zeiträume solche Typen:

C#
// src/Kladde.Booking/BookingStore.cs (Auszug)
internal sealed record StoredBooking(
    BookingId Id, int ContactId, string ContactName, DateTimeOffset Start, int Minutes);

internal static class Slot
{
    public static bool Overlap(DateTimeOffset startA, int minutesA, DateTimeOffset startB, int minutesB) =>
        startA < startB.AddMinutes(minutesB) && startB < startA.AddMinutes(minutesA);

    public static bool IsValidLength(int minutes) => minutes is >= 15 and <= 480 && minutes % 15 == 0;
}

Ausprobiert wurde es in einer Wegwerf-Kopie: Kladde.Booking bekam einen Verweis auf das Projekt Kladde.Contacts (nicht auf den Vertrag) und eine Klasse, die dessen Speicher anlegt: internal static class Peek { public static int Count() => new ContactStore().All.Count; }. Der Build endete mit dem Exit-Code 1 und error CS0122: 'Der Zugriff auf "ContactStore" ist aufgrund des Schutzgrads nicht möglich. Der Verweis ging durch, der Zugriff aufs Innere nicht: die erste Linie der Verteidigung. Dass kein Modul auf das Projekt eines anderen verweist, prüft W11.2.2 mit einem Test.

Zwei Ausnahmen gibt es, beide begründet. Die Modulklasse (BookingModule) ist öffentlich, weil der Host sie aufruft. Die Seiten sind öffentlich, weil Razor für jede Komponente eine öffentliche Klasse erzeugt; sie dürfen trotzdem Internes benutzen, ListBookingsPage liest BookingStore mit @inject, und das kompiliert. Die Tests eines Moduls dürfen mehr sehen, für genau ein Testprojekt (K4 4.3.6 erklärt die Zeile): <InternalsVisibleTo Include="Kladde.Booking.Tests" />. SlotTests prüft darüber die Regel für Zeiträume.

Ein Test, der die Oberfläche zählt

Ein public ist schnell dazugeschrieben, und der Build merkt nichts. Deshalb zählt ein Test die öffentliche Oberfläche jedes Moduls. Erlaubt sind die Modulklasse, die von Razor erzeugte leere Klasse _Imports und Komponenten im Namensraum .Features.:

C#
// tests/Kladde.Architecture.Tests/ModuleSurfaceTests.cs (Auszug)
[Theory]
[MemberData(nameof(Modules))]   // ContactsModule, BookingModule, TendersModule: Assembly und Klassenname
public void OnlyTheModuleClassAndItsPagesArePublic(Assembly module, string moduleClass)
{
    Type[] exported = module.GetExportedTypes();

    foreach (Type type in exported)
    {
        bool isModuleClass = type.Name == moduleClass || type.Name == "_Imports";
        bool isPage = type.Namespace?.Contains(".Features.", StringComparison.Ordinal) == true
            && typeof(IComponent).IsAssignableFrom(type);
        (isModuleClass || isPage).ShouldBeTrue($"{type.FullName} is public in {module.GetName().Name}.");
    }

    exported.Select(t => t.Name).ShouldContain(moduleClass);
}

Wird in der Kopie internal static class Slot zu public static class Slot, bleibt der Build grün, und der Test wird rot: Kladde.Booking.Slot is public in Kladde.Booking.

Die Registrierung: eine Methode je Modul

C#
// src/Kladde.Booking/BookingModule.cs
public static class BookingModule
{
    public static IServiceCollection AddBookingModule(this IServiceCollection services)
    {
        services.AddSingleton<BookingStore>();
        services.AddScoped<IBookingService, RequestBookingHandler>();
        return services;
    }
}

Der Host sieht IBookingService aus dem Vertrag; dass RequestBookingHandler, eine interne Klasse, dahintersteht, weiß nur das Modul. Die Lebensdauern wählt jedes Modul selbst: Der Speicher ist ein Singleton, weil er im Speicher liegt und alle Anfragen ihn teilen; der Dienst ist je Anfrage neu. Eine Methode je Modul hat einen zweiten Nutzen: Ein Test steckt genau die Module in einen Container, die er braucht.

Eigene Daten, Frage und Ereignis

Jedes Modul besitzt seine Daten. In der Lösung sind es Listen im Speicher; eine echte Kladde hätte einen DbContext je Modul mit einem Schema je Modul, wie K4 es zeigt. ContactStore hält Tags und die Funktion „Schulleitung“, die der Vertrag nie ausgibt; wer einen Kontakt braucht, fragt IContactDirectory. Zwei Arten der Verständigung reichen, beide im selben Prozess:

  • Die Frage. Booking ruft FindAsync auf und bekommt eine ContactSummary; es braucht die Antwort, um fortzufahren.
  • Das Ereignis. Steht eine Buchung, sagt Booking es weiter, ohne zu wissen, wer zuhört. BookingConfirmed liegt im Vertrag von Booking, die Mechanik im Shared Kernel:
C#
// src/Kladde.SharedKernel/Events.cs (Auszug; IEventPublisher entsprechend mit PublishAsync)
public interface IEventHandler<in TEvent>
{
    Task HandleAsync(TEvent integrationEvent, CancellationToken cancellationToken);
}

Der InProcessEventPublisher holt alle IEventHandler<TEvent> aus dem Container und ruft sie in einer Schleife nacheinander auf. Das ist die kleinste Lösung, die läuft, und der Code zeigt ihre Eigenschaften: Die Handler laufen nacheinander innerhalb der Anfrage, eine Ausnahme in einem Handler kommt bis zu PublishAsync, und es gibt weder Wiederholung noch Warteschlange. Ob das genügt, entscheiden die Anforderungen; K4 zeigt Domain Events (4.3.4) und die sichere Zustellung über einen Postausgang (4.6.2). Der Handler der Buchung steht im Ordner seiner Funktion:

C#
// src/Kladde.Booking/Features/RequestBooking/RequestBookingHandler.cs (Auszug)
ContactSummary? contact = await contacts.FindAsync(request.Contact, cancellationToken);
if (contact is null)
{
    return new BookingOutcome(null, "Der Kontakt ist unbekannt.");
}

var id = new BookingId(Guid.NewGuid());
if (!store.TryAdd(new StoredBooking(id, contact.Id.Value, contact.Name, request.Start, request.Minutes)))
{
    return new BookingOutcome(null, "Der Zeitraum ist schon vergeben.");
}

await events.PublishAsync(new BookingConfirmed(id, contact.Id, request.Start, request.Minutes), cancellationToken);
return new BookingOutcome(id, null);

(Davor prüft Slot.IsValidLength die Dauer.) TryAdd prüft und trägt unter einer Sperre ein, damit zwei gleichzeitige Anfragen nicht beide den freien Zeitraum sehen. Der Name wird als Schnappschuss kopiert (W11.1.1), erwartbare Fälle kommen als Wert zurück.

Ein Modul ohne Host testen

C#
// tests/Kladde.Booking.Tests/BookingModuleTests.cs (Auszug)
ServiceProvider provider = new ServiceCollection()
    .AddSharedKernel()
    .AddContactsModule()
    .AddBookingModule()
    .AddSingleton<IEventHandler<BookingConfirmed>>(new Recorder(seen))   // sammelt die Ereignisse
    .BuildServiceProvider();
// ...
BookingOutcome outcome = await bookings.RequestAsync(new BookingRequest(new ContactId(1), Nine, 30), default);

outcome.IsConfirmed.ShouldBeTrue();
seen.ShouldHaveSingleItem().Id.ShouldBe(outcome.Id!.Value);

Nine ist ein fester Zeitpunkt, 9 Uhr an einem Tag im März des Beispiels. Ein zweiter Test prüft die Meldungen für einen überlappenden Zeitraum und einen unbekannten Kontakt. Kein Webserver, kein Browser, keine Datenbank; vom Modul Contacts kennt der Test nur dessen Registrierungsmethode.

Vertikale Schnitte und die Seiten im Host

In Kladde.Booking liegt ein Ordner je Funktion, nicht ein Ordner Pages und einer Services:

src/Kladde.Booking/
  BookingModule.cs   BookingStore.cs
  Features/
    RequestBooking/   RequestBookingPage.razor, RequestBookingHandler.cs
    ListBookings/     ListBookingsPage.razor

Wer „Termin buchen“ ändert, öffnet einen Ordner: Kommt ein Feld hinzu, ändern sich BookingRequest, die Seite und der Handler. Die Liste liest den eigenen Speicher direkt, das darf innerhalb eines Moduls. Das ist das Muster, das K4 in 4.9.5 Vertical Slice nennt, im Maßstab eines Moduls. Die Seite zum Buchen benutzt nur IBookingService und ContactId aus den Verträgen, ihr Formular rendert statisch (K2 0605): @page "/buchung", @inject IBookingService Bookings, ein <form method="post" @formname="book"> mit <AntiforgeryToken /> und einem @code-Block mit dem Formularmodell.

Dass der Host die Seiten findet, braucht zwei Stellen, die die Learn-Seite zum Routing beschreibt: die Endpunkte und der Router.

C#
// src/Kladde.Web/Program.cs (Auszug)
builder.Services.AddSharedKernel().AddContactsModule().AddBookingModule().AddTendersModule();
// ...
app.MapRazorComponents<App>().AddAdditionalAssemblies(ModuleAssemblies.All);

internal static class ModuleAssemblies   // eine Liste für beide Stellen
{
    public static readonly Assembly[] All =
        [typeof(ContactsModule).Assembly, typeof(BookingModule).Assembly, typeof(TendersModule).Assembly];
}

In Routes.razor bekommt <Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="ModuleAssemblies.All" ...> dieselbe Liste. Der Host hat keine Seite der Fachlichkeit. Ein Test ruft die Seiten über den Host auf (WebApplicationFactory<Program>, Paket Microsoft.AspNetCore.Mvc.Testing, am Ende von Program.cs steht public partial class Program;): /kontakte, /buchung und /buchungen antworten mit OK und ihrem Text.

Zwei Module, ein Pfad

In einer Wegwerf-Kopie bekam Kladde.Booking eine zweite Seite mit @page "/kontakte". Der Build blieb grün, und der Seitentest für /kontakte bekam InternalServerError; die anderen beiden Seiten blieben grün. Der Fehler zeigt sich also nur auf dem betroffenen Pfad und erst zur Laufzeit. Gegenmittel sind ein Präfix je Modul in den Pfaden und ein Test, der jeden Pfad einmal aufruft; Übung 2 baut einen billigeren. Nicht gebaut ist eine Seite, die interaktiv in WebAssembly läuft: Dann muss auch der Router des Client-Projekts die Assembly kennen (Learn-Seite zum Routing, W8.1.1); die Seiten dieser Lektion rendern nur statisch.

Wie es in der Angular-Welt heißt

Manfred Steyer beschreibt die öffentliche Schnittstelle eines Moduls in der Reihe „Modern Architectures with Angular“ als eine Datei: Der index.ts führt auf, was andere Module benutzen dürfen, alles andere ist Innenleben. Das Werkzeug Sheriff behandelt jeden Ordner mit einer solchen Datei als Modul und verbietet anderen, an ihr vorbei in dessen Dateien zu greifen. Unser .Contracts-Projekt ist seine Schnittstellendatei, internal ist die Sperre. Der Unterschied liegt in der Durchsetzung: In .NET sperrt der Compiler, Sheriff prüft als ESLint-Plugin, was TypeScript nicht selbst verbietet. Gelesen wurden die Seite der Reihe und die Beschreibung der Tags in der Nx-Dokumentation; das Buch „Enterprise Angular“ ist nicht gelesen.

Übung 1: Erst denken

Die Kladde bekommt ein Modul Kladde.Invoices. Entscheiden Sie je Typ: Vertrag, öffentlich in der Modulklasse, öffentlich als Seite oder internal? Kein Code.

  1. InvoiceId, die andere Module brauchen, um auf eine Rechnung zu verweisen.
  2. InvoiceRepository, das die Rechnungen speichert.
  3. InvoicesModule mit AddInvoicesModule.
  4. InvoiceListPage.razor mit @page "/rechnungen".
  5. InvoiceRounding, die Regel, wie Beträge gerundet werden.
  6. InvoiceIssued, ein Ereignis für Booking.

So prüfen Sie: Zu jedem Typ steht ein Ort und ein Satz Begründung. Welche Typen landen in dem Projekt, das nur .NET kennen darf?

Lösung 1

  1. Vertrag: andere müssen den Typ benennen, ohne Rechnungen zu verstehen.
  2. internal: der Speicher gehört dem Modul.
  3. Öffentlich in der Modulklasse: der Host ruft sie auf.
  4. Öffentlich als Seite: Razor erzeugt die Klasse öffentlich, der Oberflächentest erlaubt sie unter .Features..
  5. internal: Rundung ist Sache der Rechnungen, wie Slot bei der Buchung.
  6. Vertrag: auf ein Ereignis verlassen sich andere. Es liegt im Vertrag des Moduls, das es auslöst; der Handler in Booking ist internal in Booking.

In den Vertrag gehören 1 und 6. Wer InvoiceRounding dorthin legt, damit Booking auch rundet, hat eine Regel der Rechnungen verteilt.

Übung 2: Die Grenze brechen

  1. Setzen Sie in Booking Slot auf public und lassen Sie dotnet test tests/Kladde.Architecture.Tests laufen. Notieren Sie die Meldung.
  2. Legen Sie in Booking eine zweite Seite mit @page "/kontakte" an und rufen Sie die Seiten über den Host-Test auf. Notieren Sie den Status.
  3. Schreiben Sie einen Test „kein Pfad kommt in den Modulen zweimal vor“, der den Host nicht startet.

So prüfen Sie: Zu 1 und 2 haben Sie Meldung und Status, vor dem Zurücksetzen. Der Test aus 3 wird mit der doppelten Seite rot und nennt beide Seiten.

Lösung 2

  1. Der Build bleibt grün, der Test meldet Kladde.Booking.Slot is public in Kladde.Booking., der Lauf endet mit 1.
  2. /kontakte bekommt InternalServerError, /buchung und /buchungen bleiben grün; Build und Start haben nichts bemerkt.
  3. Reflexion wie in ModuleSurfaceTests: die Assemblies der drei Module, an jeder Komponente das RouteAttribute lesen, nach Template gruppieren:
C#
// tests/Kladde.Architecture.Tests/RouteTests.cs (Auszug)
var routes = modules
    .SelectMany(m => m.GetExportedTypes())
    .SelectMany(t => t.GetCustomAttributes<RouteAttribute>().Select(r => (Template: r.Template, Page: t.FullName)))
    .ToList();

routes.ShouldNotBeEmpty();
foreach (var group in routes.GroupBy(r => r.Template, StringComparer.OrdinalIgnoreCase))
{
    group.Count().ShouldBe(1, $"The route {group.Key} is used by {string.Join(" and ", group.Select(r => r.Page))}.");
}

modules ist das Feld mit den drei Assemblies. Mit der doppelten Seite meldete der Test The route /kontakte is used by Kladde.Contacts.Features.ListContacts.ListContactsPage and Kladde.Booking.Features.ListBookings.DoublePage. Er findet den Fehler im Build statt beim Aufruf.

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 Kladde.Contacts, Kladde.Booking, Kladde.Tenders (je mit .Contracts), Kladde.SharedKernel, dem Host Kladde.Web und den Testprojekten Kladde.Architecture.Tests, Kladde.Booking.Tests und Kladde.Web.Tests. dotnet build Kladde.slnx -m:1 --no-incremental endete mit 0 und null Warnungen (TreatWarningsAsErrors ist an), dotnet test Kladde.slnx ebenfalls mit 0: Architecture 7 von 7, Booking 10 von 10, Web 3 von 3 Tests bestanden. Danach kam RouteTests aus der Lösung 2 hinzu (Architecture dann 8 von 8). Ausprobiert wurden in einer Kopie: der Verweis von Booking auf Kladde.Contacts mit dem Zugriff auf ContactStore (Exit-Code 1, CS0122), Slot als public (Test rot), eine doppelte Seite für /kontakte (InternalServerError, RouteTests rot). Nicht gebaut sind Kladde.UI aus W8, eine Datenbank je Modul, ein Browser, das Absenden des Formulars (die Seiten wurden nur per GET abgerufen) und interaktive Seiten in WebAssembly. Die Quellen zu Angular und Nx sind gelesen, nicht ausprobiert.

Was Sie jetzt können

  • Sie schneiden ein Modul als zwei Projekte, Fachlichkeit mit Seiten und Vertrag, und lassen den Vertrag nur auf andere Verträge verweisen.
  • Sie setzen internal als Standard, nehmen nur Modulklasse, Seiten und Vertrag aus und lassen einen Test die öffentliche Oberfläche zählen.
  • Sie melden ein Modul mit AddBookingModule an, nehmen seine Seiten über AdditionalAssemblies in Router und Endpunkte auf und halten den Host frei von Fachlichkeit.
  • Sie lassen Module über eine Abfrage-Schnittstelle und ein Ereignis sprechen und prüfen das ohne Host.
  • Sie ordnen ein Modul nach Funktionen und wissen, dass ein doppelter Seitenpfad erst zur Laufzeit auffällt, und prüfen ihn im Build.

Die Kladde: drei Module mit Verträgen, Registrierungen und Seiten, ein Host, der nur zusammensetzt. Als Nächstes geht es um die Form des Repositorys: eine Lösung, gemeinsame Build-Regeln, zentrale Paketversionen, und die Frage, ob alles in ein Repository gehört.

Weiterlesen

Alle Quellen unten wurden am Schreibtag gelesen, wenn nichts anderes dabeisteht.