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,.Contractsund.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.razorliegt inKladde.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:
<!-- 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.
// 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:
// 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.:
// 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
// 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
FindAsyncauf und bekommt eineContactSummary; es braucht die Antwort, um fortzufahren. - Das Ereignis. Steht eine Buchung, sagt Booking es weiter, ohne zu wissen, wer zuhört.
BookingConfirmedliegt im Vertrag von Booking, die Mechanik im Shared Kernel:
// 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:
// 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
// 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.
// 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.
InvoiceId, die andere Module brauchen, um auf eine Rechnung zu verweisen.InvoiceRepository, das die Rechnungen speichert.InvoicesModulemitAddInvoicesModule.InvoiceListPage.razormit@page "/rechnungen".InvoiceRounding, die Regel, wie Beträge gerundet werden.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
- Vertrag: andere müssen den Typ benennen, ohne Rechnungen zu verstehen.
internal: der Speicher gehört dem Modul.- Öffentlich in der Modulklasse: der Host ruft sie auf.
- Öffentlich als Seite: Razor erzeugt die Klasse öffentlich, der Oberflächentest erlaubt sie unter
.Features.. internal: Rundung ist Sache der Rechnungen, wieSlotbei der Buchung.- Vertrag: auf ein Ereignis verlassen sich andere. Es liegt im Vertrag des Moduls, das es auslöst; der Handler in Booking ist
internalin 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
- Setzen Sie in Booking
Slotaufpublicund lassen Siedotnet test tests/Kladde.Architecture.Testslaufen. Notieren Sie die Meldung. - 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. - 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
- Der Build bleibt grün, der Test meldet
Kladde.Booking.Slot is public in Kladde.Booking., der Lauf endet mit 1. /kontaktebekommtInternalServerError,/buchungund/buchungenbleiben grün; Build und Start haben nichts bemerkt.- Reflexion wie in
ModuleSurfaceTests: die Assemblies der drei Module, an jeder Komponente dasRouteAttributelesen, nachTemplategruppieren:
// 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.slnxmitKladde.Contacts,Kladde.Booking,Kladde.Tenders(je mit.Contracts),Kladde.SharedKernel, dem HostKladde.Webund den TestprojektenKladde.Architecture.Tests,Kladde.Booking.TestsundKladde.Web.Tests.dotnet build Kladde.slnx -m:1 --no-incrementalendete mit 0 und null Warnungen (TreatWarningsAsErrorsist an),dotnet test Kladde.slnxebenfalls mit 0: Architecture 7 von 7, Booking 10 von 10, Web 3 von 3 Tests bestanden. Danach kamRouteTestsaus der Lösung 2 hinzu (Architecture dann 8 von 8). Ausprobiert wurden in einer Kopie: der Verweis von Booking aufKladde.Contactsmit dem Zugriff aufContactStore(Exit-Code 1, CS0122),Slotalspublic(Test rot), eine doppelte Seite für/kontakte(InternalServerError,RouteTestsrot). Nicht gebaut sindKladde.UIaus 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
internalals Standard, nehmen nur Modulklasse, Seiten und Vertrag aus und lassen einen Test die öffentliche Oberfläche zählen. - Sie melden ein Modul mit
AddBookingModulean, nehmen seine Seiten überAdditionalAssembliesin 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.
- Modern Architectures with Angular – Part 1: Strategic Design with Sheriff, Manfred Steyer, ANGULARarchitects (gelesen am Schreibtag)
- Enforce Module Boundaries, Nx-Dokumentation (gelesen am Schreibtag: Tags und Abhängigkeitsregeln)
- ASP.NET Core Blazor routing, Abschnitt „Route to components from multiple assemblies“
- Consume ASP.NET Core Razor components from a Razor class library (RCL)
- MSBuild reference for .NET SDK projects, Abschnitt „InternalsVisibleTo“
- K4 4.3.6 „Der modulare Monolith“, K4 4.3.4 „Domain Events“, K4 4.6.2 „Outbox“, K4 4.9.5 (Vertical Slice)