26 - Modular Monolith & Mediator
Recap
Lecture 25 covered the why of modular monoliths — the structural argument for splitting one deployable into self-contained modules, the golden rule (modules don't reach into each other's internals), the comparison against classic monoliths and microservices, and the eventual-consistency window that opens up the moment you split data ownership. If you skipped that or read it weeks ago, glance at the Modular Monolith section of lecture 25 before continuing — this lecture builds on it directly and won't re-derive any of it.
By the end of this lecture you should be able to:
- Lay out a single module's code so it looks like Clean Architecture in miniature.
- Explain the Mediator pattern — both the original GoF idea and the messaging-style variant the .NET ecosystem uses.
- Use MediatR for two distinct jobs: in-module CQRS-lite (
IRequest/IRequestHandler) and cross-module events (INotification/INotificationHandler). - Add cross-cutting behaviour (logging, validation, transactions) without touching every handler.
- Wire up the whole thing in a composition root with one line per module.
- Spot the most common mistakes that AI assistants make when generating modular-monolith code, and prompt around them.
The migration path from lecture 25 you should keep in mind:
Classic Monolith → Clean/Onion Architecture → Modular Monolith → Microservices (only if you ever actually need to)
A module is a mini Clean Architecture
You already know Clean Architecture from lecture 25 — Domain at the centre, Application around it, Infrastructure pointing inward, Web at the edge. A module is exactly that, scoped to one bounded context.
For small modules, a single project with folders is fine:
MyApp.Modules.Persons/
├── Api/
│ └── PersonsController.cs
├── Application/
│ ├── Queries/
│ │ ├── GetPersonById/
│ │ │ ├── GetPersonByIdQuery.cs
│ │ │ ├── GetPersonByIdHandler.cs
│ │ │ └── PersonDto.cs
│ │ └── ListPersons/
│ ├── Commands/
│ │ ├── CreatePerson/
│ │ └── DeletePerson/
│ └── Events/
│ └── (handlers for events FROM other modules that Persons reacts to)
├── Domain/
│ └── Person.cs
└── Infrastructure/
├── PersonsDbContext.cs
├── PersonRepository.cs
└── PersonsModuleExtensions.cs
For larger modules, promote each folder to its own project — MyApp.Modules.Persons.Domain, .Application, .Infrastructure, .Api. The trade-off is honest:
| One project, folders | Project per layer | |
|---|---|---|
| Compile-time boundary inside the module | None — discipline only | Project references enforce it |
| Build time | Faster | More |
| Ceremony | Low | High |
| Right for | Small/early modules, students, A4 | Large modules, multi-team work |
Pick whichever matches the module's size. Don't ceremony-engineer a 200-line module into eight projects. The boundary that matters in a modular monolith is the one between modules, not the one between layers within a module — that's the Clean Architecture boundary you've already learned.
A module is Clean Architecture with a smaller scope. The dependency arrow still points inward. What's new is that another module is just as off-limits as Infrastructure was for Domain.
The Mediator pattern
Before we touch the library, a step back. The pattern is older than MediatR, and you'll meet it in places that have nothing to do with .NET — chat clients, GUI toolkits, message buses, robotics middleware.
The problem. Imagine an OrdersController that, on a single "place order" request, needs to:
- save the order,
- reserve inventory,
- charge the card,
- send a confirmation email,
- write an audit log.
Five collaborators. The naïve answer is: inject all five into the controller. Now the controller knows about every downstream service, and every controller doing similar work has the same problem. Add a sixth collaborator (loyalty points?) and you change every caller. This is N senders × M handlers — O(N×M) edges in your dependency graph.
The original GoF mediator is a central object — picture air-traffic control — that knows about all the colleagues and routes interactions between them. The colleagues talk only to the mediator. The number of edges drops to O(N+M).
The messaging-style mediator (which is what MediatR is) takes the same idea and flips the API. Instead of the mediator knowing every colleague's interface, the sender packages its intent into a message object — a request, a command, an event — and hands it to the mediator. The mediator finds the registered handler at runtime and dispatches the call.
The smallest possible example, just to anchor the shape:
// 1. Define a request — what the sender wants
public record PingRequest(string Greeting) : IRequest<string>;
// 2. Define a handler — how the system answers
public class PingHandler : IRequestHandler<PingRequest, string>
{
public Task<string> Handle(PingRequest req, CancellationToken ct)
=> Task.FromResult($"{req.Greeting}, pong");
}
// 3. Send it from anywhere that has IMediator injected
var reply = await _mediator.Send(new PingRequest("hello"));
// reply == "hello, pong"
The sender (whoever calls _mediator.Send) doesn't know PingHandler exists. The handler doesn't know who sent the request. They share one thing: the message contract PingRequest.
A mediator decouples who sends from who handles. The sender publishes intent; the framework finds the handler.
The trade-off is real and worth naming: indirection. Press F12 on _mediator.Send(...) and you don't jump to the handler — you jump to the framework. You learn to find handlers by searching for the request type. Most IDEs eventually offer a "go to handler" command; until then, this is the cost of the pattern. Pay it when you're getting decoupling in return; don't pay it for two collaborators.
MediatR — the .NET implementation
MediatR is the de-facto messaging-style mediator for .NET (now commercial). Install once:
dotnet add package MediatR
Register at startup. The trick is RegisterServicesFromAssembly — point MediatR at one type from each module's Application assembly, and it scans for all handlers in that assembly:
// Program.cs (composition root)
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(PersonsModuleMarker).Assembly);
cfg.RegisterServicesFromAssembly(typeof(OrdersModuleMarker).Assembly);
cfg.RegisterServicesFromAssembly(typeof(NotificationsModuleMarker).Assembly);
});
*ModuleMarker is just an empty class per module — a stable handle on the assembly so MediatR knows what to scan. We'll fold this into the module-extension methods in §"Wiring it up" so the composition root doesn't need to know any of it.
The three abstractions
| Abstraction | Cardinality | Used for |
|---|---|---|
IRequest<TResponse> + IRequestHandler<TRequest, TResponse> | Exactly one handler | Queries and commands |
INotification + INotificationHandler<T> | Zero or more handlers | Domain / integration events |
IStreamRequest<T> + IStreamRequestHandler<TRequest, T> | One handler, returns IAsyncEnumerable<T> | Streaming. Mentioned for completeness; not used in A4 |
Most "I'm using MediatR wrong" mistakes are confusing rows 1 and 2.
IRequest — queries and commands
A query asks for data. A command asks for change. MediatR doesn't distinguish them at the type level — both are IRequest<TResponse>. Convention: name them *Query and *Command, return a DTO from a query, return either nothing (just IRequest, no TResponse) or a small result from a command.
// Application/Queries/GetPersonById/GetPersonByIdQuery.cs
public record GetPersonByIdQuery(Guid Id) : IRequest<PersonDto?>;
public record PersonDto(Guid Id, string FirstName, string LastName, string Email);
// Application/Queries/GetPersonById/GetPersonByIdHandler.cs
internal sealed class GetPersonByIdHandler
: IRequestHandler<GetPersonByIdQuery, PersonDto?>
{
private readonly PersonsDbContext _db;
public GetPersonByIdHandler(PersonsDbContext db) => _db = db;
public async Task<PersonDto?> Handle(GetPersonByIdQuery q, CancellationToken ct)
{
var p = await _db.Persons
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == q.Id, ct);
return p is null
? null
: new PersonDto(p.Id, p.FirstName, p.LastName, p.Email);
}
}
A few things to notice:
- The handler is
internal sealed. Outside the module no one should construct it directly; they go throughIMediator. recordfor the query — it's a value, not behaviour. Equality by value is helpful for testing and caching.- The query returns
PersonDto?— nullable, because "not found" is a normal answer, not an exception.
A command without a return value uses IRequest (no generic) and IRequestHandler<TRequest>:
public record DeletePersonCommand(Guid Id) : IRequest;
internal sealed class DeletePersonHandler : IRequestHandler<DeletePersonCommand>
{
private readonly PersonsDbContext _db;
private readonly IMediator _mediator;
public DeletePersonHandler(PersonsDbContext db, IMediator mediator)
{
_db = db;
_mediator = mediator;
}
public async Task Handle(DeletePersonCommand cmd, CancellationToken ct)
{
var p = await _db.Persons.FindAsync([cmd.Id], ct);
if (p is null) return;
_db.Persons.Remove(p);
await _db.SaveChangesAsync(ct); // commit first
await _mediator.Publish(new PersonDeletedEvent(p.Id), ct); // then announce
}
}
INotification — events
// Shared.Contracts/Events/PersonCreatedEvent.cs
public record PersonCreatedEvent(Guid PersonId, string Email) : INotification;
// Modules/Notifications/Application/Handlers/SendWelcomeEmailHandler.cs
internal sealed class SendWelcomeEmailHandler : INotificationHandler<PersonCreatedEvent>
{
private readonly IEmailSender _email;
public SendWelcomeEmailHandler(IEmailSender email) => _email = email;
public Task Handle(PersonCreatedEvent e, CancellationToken ct)
=> _email.SendAsync(e.Email, "Welcome", $"Hi, welcome aboard.", ct);
}
// Modules/Audit/Application/Handlers/LogPersonCreatedHandler.cs
internal sealed class LogPersonCreatedHandler : INotificationHandler<PersonCreatedEvent>
{
private readonly IAuditLog _audit;
public LogPersonCreatedHandler(IAuditLog audit) => _audit = audit;
public Task Handle(PersonCreatedEvent e, CancellationToken ct)
=> _audit.RecordAsync($"person.created:{e.PersonId}", ct);
}
The Persons module fires _mediator.Publish(new PersonCreatedEvent(p.Id, p.Email)). Both handlers run. Persons doesn't know either exists — that's the point. Add a third subscriber tomorrow and Persons doesn't change.
About MediatR's licence
In late 2024 MediatR moved from MIT to a paid commercial licence (Jimmy Bogard / Lucky Penny Software), with a free tier for small teams and individual use. For coursework, A4, personal projects you're fine. Read the current terms before shipping a commercial product on it.
If you ever need to leave MediatR, the alternatives are real and broadly similar in shape:
- Wolverine — broader scope (messaging, sagas, scheduled jobs), MIT-licensed, slightly different API.
- Brighter — command-processor style, well-documented.
- Hand-rolled — write your own
IDispatcherover DI. The mediator pattern is not the library; it's a hundred lines of code at most.
For this course, learn MediatR. The patterns and the vocabulary transfer directly to all of the above.
Or use https://github.com/martinothamar/Mediator as alternative. It's a drop-in replacement for MediatR with a cleaner API.
- Mediator.SourceGenerator
Typically installed in edge/outermost executable projects (for example: ASP.NET Core app, worker service, Function app) Do not install it into every layer/project in the same deployed artifact, that will lead to errors - Mediator.Abstractions
Install this alongside the SourceGenerator reference and in projects that define messages and handlers
CQRS-lite inside a module
Lecture 24 showed services as the application-layer unit: PersonService.GetByIdAsync(...), PersonService.CreateAsync(...), all on one class. With MediatR you replace the service with one query/command per use-case and a matching handler. Both styles work; they have different costs.
Same operations, lecture-24 style:
public interface IPersonService
{
Task<PersonDto?> GetByIdAsync(Guid id, CancellationToken ct);
Task<PersonDto> CreateAsync(CreatePersonDto dto, CancellationToken ct);
Task UpdateAsync(UpdatePersonDto dto, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
}
public class PersonService : IPersonService { /* four methods, one class, shared dependencies */ }
Same operations, CQRS-lite style — one folder per use-case:
Application/
├── Queries/
│ ├── GetPersonById/
│ │ ├── GetPersonByIdQuery.cs
│ │ ├── GetPersonByIdHandler.cs
│ │ └── PersonDto.cs
│ └── ListPersons/
│ ├── ListPersonsQuery.cs
│ └── ListPersonsHandler.cs
└── Commands/
├── CreatePerson/
│ ├── CreatePersonCommand.cs
│ ├── CreatePersonHandler.cs
│ └── CreatePersonValidator.cs
└── DeletePerson/
├── DeletePersonCommand.cs
└── DeletePersonHandler.cs
This is "vertical slicing" — code grouped by feature rather than by technical layer. Each slice contains everything for one use-case: the request, the handler, its validator, its DTO. A new feature is a new folder, not edits in five existing files.
When does it pay off?
| Module size | Better choice | Why |
|---|---|---|
| 2–4 use-cases | Service | One file, fewer moving parts, less indirection |
| 5–15 use-cases | CQRS-lite | One folder per use-case keeps each small |
| 15+ use-cases | CQRS-lite + behaviours | Pipeline behaviours start to pay (see next section) |
A common middle ground is to use services for trivial CRUD modules and switch to CQRS-lite for the modules where you have real business logic and want pipeline behaviours.
CQRS-lite is just decoupled handlers: same database, same DbContext, no separate read model, no event sourcing. It's the organisational benefit, not the architectural one. If a colleague tells you their CRUD app "does CQRS" because they used MediatR, they mean CQRS-lite.
Talking across modules
This is the heart of A4 and the hardest thing to get right in a modular monolith. You have two patterns. Pick consciously each time.
Pattern A — Module API contracts (request/response, synchronous)
Use this when one module needs an answer from another. The contract lives in Shared.Contracts; the implementation is hidden inside the providing module.
// Shared.Contracts/Modules/IPersonModuleApi.cs
public interface IPersonModuleApi
{
Task<PersonInfoDto?> GetByIdAsync(Guid id, CancellationToken ct);
Task<bool> ExistsAsync(Guid id, CancellationToken ct);
}
public record PersonInfoDto(Guid Id, string FullName, string Email);
// Modules/Persons/Application/PersonModuleApi.cs
internal sealed class PersonModuleApi : IPersonModuleApi
{
private readonly IMediator _mediator;
public PersonModuleApi(IMediator mediator) => _mediator = mediator;
public async Task<PersonInfoDto?> GetByIdAsync(Guid id, CancellationToken ct)
{
var p = await _mediator.Send(new GetPersonByIdQuery(id), ct);
return p is null
? null
: new PersonInfoDto(p.Id, $"{p.FirstName} {p.LastName}", p.Email);
}
public Task<bool> ExistsAsync(Guid id, CancellationToken ct)
=> _mediator.Send(new PersonExistsQuery(id), ct);
}
// Modules/Orders/Application/Commands/PlaceOrder/PlaceOrderHandler.cs
internal sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly OrdersDbContext _db;
private readonly IPersonModuleApi _persons; // from Shared.Contracts only
public PlaceOrderHandler(OrdersDbContext db, IPersonModuleApi persons)
{
_db = db;
_persons = persons;
}
public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
if (!await _persons.ExistsAsync(cmd.PersonId, ct))
throw new InvalidOperationException("Unknown person.");
var order = new Order { Id = Guid.NewGuid(), PersonId = cmd.PersonId, /* ... */ };
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
The Orders module references Shared.Contracts only. It does not reference MyApp.Modules.Persons. At compile time, the Persons internals are unreachable.
Pattern B — Domain events (publish/subscribe, in-process)
Use this when a module wants to announce something happened and other modules can react if they care.
// Shared.Contracts/Events/PersonDeletedEvent.cs
public record PersonDeletedEvent(Guid PersonId) : INotification;
The publisher you've already seen — DeletePersonHandler calls _mediator.Publish(new PersonDeletedEvent(p.Id), ct) after SaveChangesAsync. Subscribers live in any module:
// Modules/Orders/Application/Events/CancelOrdersOnPersonDeleted.cs
internal sealed class CancelOrdersOnPersonDeleted : INotificationHandler<PersonDeletedEvent>
{
private readonly OrdersDbContext _db;
public CancelOrdersOnPersonDeleted(OrdersDbContext db) => _db = db;
public async Task Handle(PersonDeletedEvent e, CancellationToken ct)
{
var orders = await _db.Orders
.Where(o => o.PersonId == e.PersonId && o.Status == OrderStatus.Pending)
.ToListAsync(ct);
foreach (var o in orders) o.Cancel("person deleted");
await _db.SaveChangesAsync(ct);
}
}
Persons does not import Orders. Orders does not import Persons. They share PersonDeletedEvent and nothing else. Add a third subscriber tomorrow — Persons doesn't change.
Picking between the two
If you need an answer back, use a contract. If you're broadcasting "this happened, react if you care," use a notification.
Contracts are synchronous and explicit — one caller, one handler, the caller waits, exceptions surface, ordering is obvious. Use them when the operation can't proceed without the answer.
Notifications are loosely coupled in who knows whom — the publisher doesn't know its subscribers exist, and adding a subscriber doesn't touch the publisher. They are not loose in execution: with the default INotificationPublisher (ForeachAwaitPublisher), MediatR runs the handlers sequentially in registration order, awaits each one, and the first thrown exception aborts the rest and propagates up to the publisher. You can swap in a different publisher (one that runs handlers in parallel, or one that swallows exceptions) but you have to opt into it. Don't assume "in-process notification" means "isolated like a microservice subscriber" — it doesn't.
Use notifications when the publisher's job is done and other modules just need to know. If you need any one subscriber's failure to not affect the others, wrap that handler's body in a try/catch and log the failure — don't rely on MediatR to do it for you.
Don't mix the two patterns. A PersonExistsQuery should not be an INotification. A PersonDeletedEvent should not be an IRequest. The cardinality (one handler vs many) is the giveaway.
About consistency
Lecture 25 covered eventual consistency in detail; everything there applies. The reminder: notification handlers run after the publisher's SaveChangesAsync. There is a window — usually milliseconds in-process, but real — during which the publisher's changes are committed and the subscribers' are not. That's why an order can briefly exist for a deleted person before CancelOrdersOnPersonDeleted runs. If you need atomicity, use a contract and one transaction; if you can tolerate a window, use a notification.
Pipeline behaviours
A common annoyance: you want every command logged, every command validated, every database-touching command to run in a transaction. With services you'd put that code in every method, or invent a base class, or scatter attributes around. With MediatR you write it once, as a pipeline behaviour.
// Application/Behaviours/LoggingBehavior.cs
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _log;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> log) => _log = log;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var name = typeof(TRequest).Name;
var sw = Stopwatch.StartNew();
_log.LogInformation("Handling {Request}", name);
try
{
var result = await next();
_log.LogInformation("Handled {Request} in {Ms}ms", name, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
_log.LogError(ex, "Failed {Request} after {Ms}ms", name, sw.ElapsedMilliseconds);
throw;
}
}
}
// Registration
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
Every _mediator.Send flows through this behaviour. No handler knows. New handler tomorrow? Logged automatically.
Three behaviours that earn their keep in real apps:
- Validation. Resolve
IValidator<TRequest>from FluentValidation, run it beforenext(), throwValidationExceptionon failure. One behaviour, every command validated. - Transactions. Open a
DbContexttransaction at the start of a command, commit on success, rollback on exception. Limit to commands by tagging them with a marker interface (e.g.ITransactionalRequest). - Authorisation. Inspect a
[RequiresPermission("orders:write")]attribute on the command type, check the current user, throw if denied. Cleaner than[Authorize]on controllers because it works for any entry point — controller, hosted service, console runner.
Handlers stay pure: they answer the question, nothing else. Cross-cutting concerns plug in at the pipeline.
Pipeline behaviours are middleware for handlers. Same shape as ASP.NET Core middleware, same ordering rules, scoped to the mediator pipeline.
Wiring it up — module registration
Module registration is where modular monoliths feel real. Each module exposes one extension method; the composition root calls them in order.
// Modules/Persons/PersonsModuleExtensions.cs (folders-in-one-project layout)
public static class PersonsModuleExtensions
{
public static IServiceCollection AddPersonsModule(
this IServiceCollection services,
IConfiguration config)
{
services.AddDbContext<PersonsDbContext>(o =>
o.UseNpgsql(config.GetConnectionString("AppDb"),
npg => npg.MigrationsHistoryTable("__migrations", "persons")));
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(PersonsModuleExtensions).Assembly));
services.AddScoped<IPersonModuleApi, PersonModuleApi>();
services.AddScoped<IPersonRepository, PersonRepository>();
return services;
}
}
// Program.cs (composition root)
builder.Services
.AddPersonsModule(builder.Configuration)
.AddOrdersModule(builder.Configuration)
.AddNotificationsModule(builder.Configuration);
// Cross-cutting behaviours apply across all modules
builder.Services.AddTransient(
typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
A note on the assembly scan. RegisterServicesFromAssembly(typeof(PersonsModuleExtensions).Assembly) only finds handlers in the same assembly as PersonsModuleExtensions. That works for the folders-in-one-project layout above. If you split a module into separate .Domain, .Application, .Infrastructure, .Api projects, you have two reasonable options:
- Module-root project. Add a top-level
MyApp.Modules.Personsproject that references the layer projects and hostsPersonsModuleExtensionsplus a marker class for each scanned assembly. PassRegisterServicesFromAssemblies(typeof(SomeApplicationMarker).Assembly, typeof(PersonsModuleExtensions).Assembly). - Explicit markers. Define an empty
IPersonsApplicationMarkerinMyApp.Modules.Persons.Applicationand passtypeof(IPersonsApplicationMarker).Assemblyto the registration call. The extension still lives wherever you choose, but the scan is no longer accidentally tied to its own assembly.
Either way, the call needs to name the assembly that actually contains the handlers. Forgetting this is the most common reason "MediatR doesn't find my handler" appears in a project-per-layer setup.
Two things to notice:
- The Web project has no idea what's inside Persons. It knows one thing:
AddPersonsModule(config). Adding a fourth module is one new line inProgram.cs. - Inside the extension, almost everything registered is
internal.IPersonModuleApiis the only thing other modules ever resolve from DI.IPersonRepositoryis registered for the module itself, not for the world.
This is what makes the internal access modifier so important here. If PersonRepository is public, an Orders developer (or AI assistant) can write services.GetRequiredService<PersonRepository>() and bypass the boundary. If it's internal, the compiler refuses — the type isn't even visible from another assembly. Boundary enforced at compile time, with no extra tooling.
Boundary enforcement — keeping modules honest
Discipline is fragile. Make wrong code not compile whenever you can.
- Project references.
MyApp.Modules.Orders.csprojreferencesMyApp.Shared.Contracts. It does not referenceMyApp.Modules.Persons. Trying tousing MyApp.Modules.Persons.Domainwon't even resolve. This is your strongest enforcement. internalaccess modifiers. Handlers, repositories, DbContexts, entity classes —internal sealedby default. The onlypublictypes in a module are: its API contract (which lives inShared.Contracts), its module-extension class (AddXxxModule), and its events (also inShared.Contracts). Everything else is module-private.- Schema-per-module in EF Core. Recap from lecture 25: each
DbContextcallsb.HasDefaultSchema("persons"). Same database server, different SQL schemas. No cross-module joins. - Architecture tests. If you want belt-and-braces, use
NetArchTestorArchUnitNETto assert structural rules and run them in CI:
[Fact]
public void Orders_does_not_reference_Persons_internals()
{
var result = Types.InAssembly(typeof(OrdersModuleMarker).Assembly)
.Should()
.NotHaveDependencyOn("MyApp.Modules.Persons")
.GetResult();
Assert.True(result.IsSuccessful,
string.Join(", ", result.FailingTypeNames ?? []));
}
You don't need this for A4. You'll want it in a real codebase the first time someone slips a using MyApp.Modules.Persons.Internal; past code review.
Code-review checklist for a modular-monolith pull request:
- Project references — did anyone add a reference to another module's project?
usingdirectives — does any file importMyApp.Modules.Other.*(other thanShared.Contracts)?- DbContext — does the module have its own, with its own schema?
- Static helpers — no static cross-module utilities. They sneak through compilation.
- Public surface — anything
publicthat isn't a contract, an event, or a registration extension is suspicious.
If all five are clean, the boundary holds.
Result pattern
Results instead of exceptions
Returning a Result object indicating success or failure - comes from functional programming languages.
Read this: https://enterprisecraftsmanship.com/posts/exceptions-for-flow-control/
public Result DoTask()
{
if (this.State == TaskState.Done)
return Result.Fail("Task is in the wrong state.");
// rest of the logic
return Result.Ok();
}
Look into https://github.com/altmann/FluentResults and https://github.com/mcintyre321/OneOf
Or wait for C# union types (C# 15, november 2026)
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union
- https://github.com/dotnet/csharplang/blob/main/meetings/working-groups/discriminated-unions/union-proposals-overview.md
- Exhaustive reasoning: The compiler can ensure you’ve handled every case in a switch, removing fall-through bugs.
- Precise domain modeling: Represent “either X or Y (or Z)” as a single type safely.
- Clearer APIs: Return a single result type instead of coupling exceptions, booleans, and out parameters.
- Fewer nulls and invalid states: Illegal states become unrepresentable.
If you’ve used OneOf<T…>, FluentResults, or hand-rolled patterns with records and pattern matching, unions make these scenarios first-class and more efficient.
AI-tool guidance
You use AI to write code. So does everyone in this room, and so will every team you join. A modular monolith is one of the architectures where AI assistants are weakest — they were trained on years of Stack Overflow answers and tutorials full of layered monoliths, and they default to "simplest thing that compiles," which usually means crossing your boundaries.
This section is about reading AI output, not about avoiding AI.
What AI tends to get wrong
- Direct DbContext injection across modules. You ask for a feature in Orders that involves a person; the AI happily injects
PersonsDbContextinto your Orders handler "to save a step." Boundary gone. - Helpful flattening. You tell it modules talk via
IPersonModuleApi. It "simplifies" by replacing the contract call with a direct method call intoPersonService— the diff looks shorter, the coupling is back. - One mega-DbContext. AI proposes
AppDbContextwith every entity from every module, "for consistency." This is exactly the layered monolith we're trying to leave. - Skipping
internal. AI defaults topublicbecause that's what most training data uses.public PersonRepositorymeans any module can resolve it from DI. Boundary unenforceable. - Confusing
IRequestwithINotification. A "do this and tell others" command becomes one giantINotificationwith a side-effecting handler that returns nothing useful, or vice versa. - Skipping the publish-after-commit rule. AI publishes the event before
SaveChangesAsyncbecause the diff reads more naturally that way. Now subscribers handle an event for a transaction that hasn't committed.
How to prompt better
The pattern that works: give the AI the boundary in the prompt.
"Add a query to get a person by id. Use MediatR
IRequest<PersonDto?>. The handler must live insideMyApp.Modules.Persons.Applicationand beinternal sealed. Do not expose the DbContext or thePersonentity outside the module."
"Add a
PersonCreatedEvent : INotificationinMyApp.Shared.Contracts.Events. Publish it fromCreatePersonHandlerafterSaveChangesAsync. Also add aSendWelcomeEmailHandler : INotificationHandler<PersonCreatedEvent>insideMyApp.Modules.Notificationsthat usesIEmailSender. The Notifications module must not reference the Persons module."
Two patterns to repeat:
- Name the project the code goes in.
MyApp.Modules.X.Application, not "the application layer." - State the boundary as a negative. "must not reference Persons", "must be
internal", "must not import the Persons DbContext." Negatives are easier to verify than positives.
Verification checklist for AI output
After every meaningful AI suggestion, run this in your head before accepting:
- Open the new file's
.csproj. The only project references should be: this module's lower layers andMyApp.Shared.Contracts. Anything else fails. - Search the file for
using MyApp.Modules.. The only allowed match is the current module's own namespaces. - Handlers, repositories, and DbContexts are
internal sealed. Contracts and events arepublicand live inShared.Contracts. - The module has its own
DbContext, not a shared one. - Cross-module calls go through either
IXxxModuleApi(for answers) orINotification(for events). Direct method calls into another module are a bug.
Vocabulary worth using in prompts
modular monolith, MediatR, IRequest<T>, INotification, IRequestHandler, INotificationHandler, IPipelineBehavior, internal sealed, module API contract, Shared.Contracts, schema-per-module, vertical slice, composition root, RegisterServicesFromAssembly, publish after SaveChangesAsync.
These are the words that pin the AI to the right corner of its training data. Vague prompts ("add a service for persons") give you a layered-monolith answer. Specific prompts ("add a GetPersonByIdQuery : IRequest<PersonDto?> with an internal sealed handler in MyApp.Modules.Persons.Application") give you the answer that fits the architecture.
When not to use this
A modular monolith is a discipline. A discipline only pays for itself when there is enough code to discipline.
- Small CRUD apps with one team. Two-three entities, one developer, six controllers? Use the layered approach from lectures 21–24. Services, repositories, one DbContext. Don't manufacture modules.
- Performance. MediatR adds a microsecond or two of overhead per call — negligible. The cost is cognitive: more files, more indirection, harder to F12-jump. Pay it when the architecture earns it.
- Library size. MediatR is one library. The mediator pattern is a hundred lines of code. Some teams ship an
IDomainEventDispatcherinterface with a tinyforeachimplementation and skip MediatR entirely. The pattern is what matters.
The signals that a modular monolith does pay off:
- Two or more bounded contexts that you can name in a sentence each.
- Multiple developers stepping on each other's code in the same files.
- A backlog where the next big feature is mostly in one bounded context, and the one after that is mostly in another.
- A long-term suspicion that one of the modules might one day need to be its own service.
If none of those apply, save the ceremony for when they do. A4 deliberately gives you a context where they do apply, so you'll feel both the cost and the payoff.
Self preparation QA
Be prepared to explain topics like these:
- What problem does the mediator pattern solve, and what is its trade-off? — It collapses N×M direct couplings between senders and handlers into N+M edges through a hub. The trade-off is indirection — F12 from a sender doesn't jump to the handler; you find handlers by searching for the request type.
- When do you use
IRequest<T>vsINotificationin MediatR? —IRequest<T>for exactly one handler, when you need an answer (queries) or a definite outcome (commands).INotificationfor zero or more handlers, when you're announcing that something happened and other modules can react if they care. - How is a single module structured internally, and how is that different from a service in a layered app? — A module is Clean Architecture in miniature: Domain, Application (handlers, DTOs), Infrastructure (DbContext, repositories), Api (controllers). A service in a layered app exposes its behaviour as a class and depends downward on a shared DAL. A module is self-contained: its own DbContext, its own schema, its own handlers, all
internal. - What are the two patterns for cross-module communication and when do you pick each? — Module API contracts (interfaces in
Shared.Contracts, implemented internally) for synchronous request/response when you need an answer. Domain events (INotificationinShared.Contracts, multiple handlers across modules) for in-process broadcasts where the publisher doesn't care who reacts. Note that with MediatR's default publisher, notification handlers run sequentially and an exception in one aborts the rest — loose coupling at design time, not at runtime. If you need an answer back, use a contract; if you're announcing, use a notification. - How does the
internalaccess modifier help enforce module boundaries? —internaltypes are visible only inside their assembly. Marking handlers, repositories, DbContexts, and entity classesinternalmeans another module cannot resolve them from DI, even if it tried, because the type isn't reachable. The onlypublicsurface is the module's API contract and its registration extension. Boundary enforced at compile time. - What would you check first when reviewing AI-generated module code? — The
.csprojreferences and theusingdirectives. If a module references another module's project, or anyusing MyApp.Modules.Other.*appears (whereOtheris anything other thanShared.Contracts), the boundary is broken. Then verify handlers and repositories areinternal sealed, and that the module has its ownDbContext. - Why is "modular monolith → microservices" an easy migration, while "classic monolith → microservices" is painful? — The modular monolith already has the structure: separate DbContexts, clean module API contracts, in-process events. Extracting a module means swapping the in-process
IXxxModuleApicall for an HTTP/gRPC client, and the in-processINotificationfor a message broker. The transport changes; the code structure barely does. A classic monolith has none of that — entities reference each other, the DbContext is shared, services call each other directly. Extraction means first doing the modular-monolith work, then the network work. Two migrations, not one.