The context map from the previous lesson is on paper and is compared with the solution by a test. What is missing is the shape of a module in code: What may another module see, how does a module sign up with the host, who owns the pages, and how do two modules talk to each other without knowing each other? This lesson builds the answers on the Kladde, and each of them is checked by something, by the compiler or by a test. That a module is a project with a contract is known to you from K4; what is new is what that means for a Blazor application: the pages move into the module too.
Where the Kladde stands
The lesson presupposes W11.1.1 (four contexts, docs/context-map.md, the test that compares map and solution) and from K4 the lesson 4.3.6 "The modular monolith". It covers why modules come before microservices, how one schema per module separates the data, what InternalsVisibleTo is and how an architecture test checks the direction of project references; this lesson does not repeat that. Two things are deliberately different here from K4, and both hold for the rest of the course:
- A module is small: one project and its contract. K4 cuts every module into
.Domain,.Application,.Contractsand.Sql. The solution of this course should be showable in full in one lesson, so the layers lie as folders in one project. Whoever needs more separation splits it later following K4; the outer boundary does not change as a result. - The pages belong to the module. In K4, pages stay in the host, in one folder per module. Here every module is a Razor class library, and
RequestBookingPage.razorlies inKladde.Booking. The host has no business page anymore.
The solution is the one from W11.1.1; new are registrations, pages, events and test projects. Kladde.UI from W8 is not part of it.
One module, two projects
src/Kladde.Contacts.Contracts/ Contacts.cs ContactId, ContactSummary, IContactDirectory
src/Kladde.Contacts/ ContactsModule.cs the sign-up with the host
ContactStore.cs the module's data (internal)
Features/ListContacts/ one page
The project files are short, and TargetFramework is missing because it lives in a shared file (W11.2.1). The contract is an empty <Project Sdk="Microsoft.NET.Sdk">. The module is a Razor class library, because it contains pages:
<!-- 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>
The module writes its contract itself: types and interfaces, no logic.
// 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 references Kladde.Contacts.Contracts and nothing else: BookingRequest carries a ContactId. Booking knows how to name a contact but not what a contact is; that is the customer-and-supplier relationship of the map, read in code.
internal as the default
Everything that is not in the contract is internal. In Kladde.Booking, the storage and the rule for time slots are such types:
// src/Kladde.Booking/BookingStore.cs (excerpt)
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;
}
It was tried out in a throwaway copy: Kladde.Booking got a reference to the project Kladde.Contacts (not to the contract) and a class that creates its storage: internal static class Peek { public static int Count() => new ContactStore().All.Count; }. The build ended with exit code 1 and error CS0122: 'ContactStore' is inaccessible due to its protection level. The reference went through, the access to the inside did not: the first line of defense. That no module references another module's project is checked by W11.2.2 with a test.
There are two exceptions, both justified. The module class (BookingModule) is public because the host calls it. The pages are public because Razor generates a public class for every component; they may still use internals, ListBookingsPage reads BookingStore with @inject, and that compiles. The tests of a module may see more, for exactly one test project (K4 4.3.6 explains the line): <InternalsVisibleTo Include="Kladde.Booking.Tests" />. SlotTests checks the rule for time slots through it.
A test that counts the surface
A public is quickly typed, and the build notices nothing. That is why a test counts the public surface of every module. Allowed are the module class, the empty class _Imports that Razor generates and components in the namespace .Features.:
// tests/Kladde.Architecture.Tests/ModuleSurfaceTests.cs (excerpt)
[Theory]
[MemberData(nameof(Modules))] // ContactsModule, BookingModule, TendersModule: assembly and class name
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);
}
If, in the copy, internal static class Slot becomes public static class Slot, the build stays green and the test turns red: Kladde.Booking.Slot is public in Kladde.Booking.
The registration: one method per module
// 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;
}
}
The host sees IBookingService from the contract; that RequestBookingHandler, an internal class, stands behind it is known only to the module. Each module chooses the lifetimes itself: the storage is a singleton, because it lives in memory and all requests share it; the service is new per request. One method per module has a second benefit: a test puts exactly the modules it needs into a container.
Own data, question and event
Every module owns its data. In the solution these are lists in memory; a real Kladde would have a DbContext per module with one schema per module, as K4 shows. ContactStore holds tags and the function "principal", which the contract never gives out; whoever needs a contact asks IContactDirectory. Two kinds of communication suffice, both in the same process:
- The question. Booking calls
FindAsyncand gets aContactSummary; it needs the answer to continue. - The event. Once a booking stands, Booking passes it on without knowing who is listening.
BookingConfirmedlies in the contract of Booking, the mechanics in the shared kernel:
// src/Kladde.SharedKernel/Events.cs (excerpt; IEventPublisher likewise with PublishAsync)
public interface IEventHandler<in TEvent>
{
Task HandleAsync(TEvent integrationEvent, CancellationToken cancellationToken);
}
The InProcessEventPublisher fetches all IEventHandler<TEvent> from the container and calls them one after the other in a loop. That is the smallest solution that runs, and the code shows its properties: the handlers run one after the other inside the request, an exception in a handler comes up to PublishAsync, and there is neither retry nor queue. Whether that suffices is decided by the requirements; K4 shows domain events (4.3.4) and safe delivery through an outbox (4.6.2). The handler of the booking lies in the folder of its function:
// src/Kladde.Booking/Features/RequestBooking/RequestBookingHandler.cs (excerpt)
ContactSummary? contact = await contacts.FindAsync(request.Contact, cancellationToken);
if (contact is null)
{
return new BookingOutcome(null, "The contact is unknown.");
}
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, "The time slot is already taken.");
}
await events.PublishAsync(new BookingConfirmed(id, contact.Id, request.Start, request.Minutes), cancellationToken);
return new BookingOutcome(id, null);
(Before that, Slot.IsValidLength checks the duration.) TryAdd checks and enters under a lock, so that two simultaneous requests do not both see the free time slot. The name is copied as a snapshot (W11.1.1), expected cases come back as a value.
Testing a module without the host
// tests/Kladde.Booking.Tests/BookingModuleTests.cs (excerpt)
ServiceProvider provider = new ServiceCollection()
.AddSharedKernel()
.AddContactsModule()
.AddBookingModule()
.AddSingleton<IEventHandler<BookingConfirmed>>(new Recorder(seen)) // collects the events
.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 is a fixed point in time, 9 a.m. on a day in March in the example. A second test checks the messages for an overlapping time slot and an unknown contact. No web server, no browser, no database; of the module Contacts, the test knows only its registration method.
Vertical slices and the pages in the host
In Kladde.Booking there is one folder per function, not a folder Pages and one Services:
src/Kladde.Booking/
BookingModule.cs BookingStore.cs
Features/
RequestBooking/ RequestBookingPage.razor, RequestBookingHandler.cs
ListBookings/ ListBookingsPage.razor
Whoever changes "book an appointment" opens one folder: if a field is added, BookingRequest, the page and the handler change. The list reads its own storage directly, which is allowed inside a module. That is the pattern that K4 calls a vertical slice in 4.9.5, at the scale of a module. The page for booking uses only IBookingService and ContactId from the contracts, its form renders statically (K2 0605): @page "/booking", @inject IBookingService Bookings, a <form method="post" @formname="book"> with <AntiforgeryToken /> and a @code block with the form model.
For the host to find the pages, two places are needed, which the Learn page on routing describes: the endpoints and the router.
// src/Kladde.Web/Program.cs (excerpt)
builder.Services.AddSharedKernel().AddContactsModule().AddBookingModule().AddTendersModule();
// ...
app.MapRazorComponents<App>().AddAdditionalAssemblies(ModuleAssemblies.All);
internal static class ModuleAssemblies // one list for both places
{
public static readonly Assembly[] All =
[typeof(ContactsModule).Assembly, typeof(BookingModule).Assembly, typeof(TendersModule).Assembly];
}
In Routes.razor, <Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="ModuleAssemblies.All" ...> gets the same list. The host has no business page. A test calls the pages through the host (WebApplicationFactory<Program>, package Microsoft.AspNetCore.Mvc.Testing, at the end of Program.cs there is public partial class Program;): /contacts, /booking and /bookings answer with OK and their text.
Two modules, one path
In a throwaway copy, Kladde.Booking got a second page with @page "/contacts". The build stayed green, and the page test for /contacts got InternalServerError; the other two pages stayed green. So the error shows only on the affected path and only at run time. Remedies are a prefix per module in the paths and a test that calls every path once; exercise 2 builds a cheaper one. Not built is a page that runs interactively in WebAssembly: then the router of the client project must also know the assembly (Learn page on routing, W8.1.1); the pages of this lesson render only statically.
What it is called in the Angular world
Manfred Steyer describes the public interface of a module in the series "Modern Architectures with Angular" as one file: the index.ts lists what other modules may use, everything else is internals. The tool Sheriff treats every folder with such a file as a module and forbids others to reach past it into its files. Our .Contracts project is its interface file, internal is the lock. The difference lies in the enforcement: in .NET the compiler locks, Sheriff checks as an ESLint plugin what TypeScript does not forbid itself. What was read are the page of the series and the description of the tags in the Nx documentation; the book "Enterprise Angular" has not been read.
Exercise 1: Think first
The Kladde gets a module Kladde.Invoices. For each type, decide: contract, public in the module class, public as a page, or internal? No code.
InvoiceId, which other modules need to refer to an invoice.InvoiceRepository, which stores the invoices.InvoicesModulewithAddInvoicesModule.InvoiceListPage.razorwith@page "/invoices".InvoiceRounding, the rule for how amounts are rounded.InvoiceIssued, an event for Booking.
How to check: For each type, there is a place and one sentence of reasoning. Which types end up in the project that may know only .NET?
Solution 1
- Contract: others must name the type without understanding invoices.
internal: the storage belongs to the module.- Public in the module class: the host calls it.
- Public as a page: Razor generates the class as public, the surface test allows it under
.Features.. internal: rounding is a matter for invoices, likeSlotfor the booking.- Contract: others rely on an event. It lies in the contract of the module that raises it; the handler in Booking is
internalin Booking.
1 and 6 belong in the contract. Whoever puts InvoiceRounding there so that Booking rounds too has spread a rule of invoices around.
Exercise 2: Break the boundary
- In Booking, set
Slottopublicand rundotnet test tests/Kladde.Architecture.Tests. Note the message. - Create a second page in Booking with
@page "/contacts"and call the pages through the host test. Note the status. - Write a test "no path occurs twice in the modules" that does not start the host.
How to check: For 1 and 2, you have message and status, before resetting. The test from 3 turns red with the duplicate page and names both pages.
Solution 2
- The build stays green, the test reports
Kladde.Booking.Slot is public in Kladde.Booking., the run ends with 1. /contactsgetsInternalServerError,/bookingand/bookingsstay green; build and start noticed nothing.- Reflection as in
ModuleSurfaceTests: the assemblies of the three modules, read theRouteAttributeon every component, group byTemplate:
// tests/Kladde.Architecture.Tests/RouteTests.cs (excerpt)
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 is the field with the three assemblies. With the duplicate page, the test reported The route /contacts is used by Kladde.Contacts.Features.ListContacts.ListContactsPage and Kladde.Booking.Features.ListBookings.DoublePage. It finds the error in the build instead of on the call.
Status of verification. A throwaway solution outside the repository was built and tested with the .NET SDK 10.0.400 on
net10.0:Kladde.slnxwithKladde.Contacts,Kladde.Booking,Kladde.Tenders(each with.Contracts),Kladde.SharedKernel, the hostKladde.Weband the test projectsKladde.Architecture.Tests,Kladde.Booking.TestsandKladde.Web.Tests.dotnet build Kladde.slnx -m:1 --no-incrementalended with 0 and zero warnings (TreatWarningsAsErrorsis on),dotnet test Kladde.slnxalso with 0: Architecture 7 of 7, Booking 10 of 10, Web 3 of 3 tests passed. After that,RouteTestsfrom solution 2 was added (Architecture then 8 of 8). Tried out in a copy: the reference from Booking toKladde.Contactswith the access toContactStore(exit code 1, CS0122),Slotaspublic(test red), a duplicate page for/contacts(InternalServerError,RouteTestsred). Not built areKladde.UIfrom W8, a database per module, a browser, submitting the form (the pages were only called with GET) and interactive pages in WebAssembly. The sources on Angular and Nx have been read, not tried out.
What you can do now
- You cut a module as two projects, business logic with pages and contract, and let the contract reference only other contracts.
- You set
internalas the default, exempt only module class, pages and contract, and have a test count the public surface. - You register a module with
AddBookingModule, add its pages to router and endpoints throughAdditionalAssembliesand keep the host free of business logic. - You let modules talk through a query interface and an event and check that without the host.
- You arrange a module by functions, know that a duplicate page path shows only at run time, and check it in the build.
The Kladde: three modules with contracts, registrations and pages, a host that only puts things together. Next comes the shape of the repository: one solution, shared build rules, central package versions, and the question whether everything belongs in one repository.
Further reading
All sources below were read on the day of writing, unless stated otherwise.
- Modern Architectures with Angular – Part 1: Strategic Design with Sheriff, Manfred Steyer, ANGULARarchitects (read on the day of writing)
- Enforce Module Boundaries, Nx documentation (read on the day of writing: tags and dependency rules)
- ASP.NET Core Blazor routing, section "Route to components from multiple assemblies"
- Consume ASP.NET Core Razor components from a Razor class library (RCL)
- MSBuild reference for .NET SDK projects, section "InternalsVisibleTo"
- K4 4.3.6 "The modular monolith", K4 4.3.4 "Domain events", K4 4.6.2 "Outbox", K4 4.9.5 (Vertical slice)