Modular Architecture
By the end of this lesson
Draw module boundaries inside one application before splitting it.
A module is a slice of the application that owns a business capability end to end: its own entities, its own data, its own rules, and a small public contract that other modules use. Orders, Invoicing, Employees. Everything ships together as one application, in one process, behind one deployment.
This is the arrangement most teams should reach for, and it deserves saying plainly rather than as a compromise: get the boundaries right inside one deployable first. You get most of what separation buys — independent reasoning, contained changes, clear ownership — without network calls, distributed transactions or eight deployment pipelines. If a genuine need to split arrives later, the seams already exist and you split along a line you have been living with rather than one you guessed at.
What makes a module a module rather than a folder:
- It is named after a business capability, not a technical layer. Invoicing, not Repositories.
- It owns its tables. No other module reads or writes them, not even for a convenient join.
- Its types are internal by default. Other modules see one small contract and nothing else.
- It receives work through that contract or by handling events, never by another module reaching inside.
- It registers its own dependencies, so adding it to the host is one line.
- It can be tested on its own, with the rest of the application absent.
// Invoicing module, public surface — the only types other modules may use
public interface IInvoicingApi
{
Task<InvoiceSummary> CreateDraftForOrderAsync(NewInvoiceRequest request, CancellationToken token);
}
public sealed record NewInvoiceRequest(int OrderId, int CustomerId, IReadOnlyList<InvoiceLineDto> Lines);
public sealed record InvoiceSummary(int InvoiceId, string Number, decimal Total);
// Invoicing module, internals — not visible outside this project
internal sealed class InvoicingApi(InvoicingDbContext db, IInvoiceNumbering numbering) : IInvoicingApi
{
public async Task<InvoiceSummary> CreateDraftForOrderAsync(
NewInvoiceRequest request, CancellationToken token)
{
Invoice invoice = Invoice.DraftFor(request, await numbering.NextAsync(token));
db.Invoices.Add(invoice);
await db.SaveChangesAsync(token);
return new InvoiceSummary(invoice.Id, invoice.Number, invoice.Total);
}
}
// Invoicing module, registration — the host calls this and knows nothing else
public static class InvoicingModule
{
public static IServiceCollection AddInvoicing(this IServiceCollection services, string connection)
{
services.AddDbContext<InvoicingDbContext>(options => options.UseSqlServer(connection));
services.AddScoped<IInvoiceNumbering, SequentialNumbering>();
services.AddScoped<IInvoicingApi, InvoicingApi>();
return services;
}
}- The contract is three types: one interface and two records. Anything the Orders module needs from Invoicing goes through them, and the summary it gets back is a copy, not an entity it could modify.
- The implementation, the DbContext and the Invoice entity are internal. In C#, internal means visible inside the same assembly, so this containment is only enforced if each module is its own project. Inside a single project internal does nothing, and you are relying on discipline plus code review — which works until the week it does not.
- InvoicingDbContext maps only invoicing tables. Separate contexts over one database is a practical middle ground: one connection string, one transaction when you need it, but no module can quietly query another's tables because its context does not know they exist.
- AddInvoicing is the module's whole wiring story. Program.cs calls it and stays free of the module's internals, so removing the module later is one deleted line and one deleted project.
The same boundaries, drawn inside one process or across several:
| Modules in one deployable | Separate services | |
|---|---|---|
| Calling another capability | A method call through an interface; it succeeds or throws | A network call that can also time out, half-succeed or be retried |
| Changing two capabilities together | One commit, one deployment, one transaction if needed | Coordinated releases, versioned contracts, no shared transaction |
| Deployment | One pipeline | One pipeline per service, plus configuration and secrets for each |
| Debugging a flow | One stack trace, one debugger | Correlated logs across processes |
| Team independence | Shared release schedule | Each team releases on its own schedule |
| Scaling | More instances of the whole application | More instances of the part that needs it |
The seam is the point of all this. A module that only talks through a contract can become a separate service by replacing that contract's implementation with a network call, while every caller stays as it is. The boundary was already there; what changes is the transport.
That is why boundaries inside one application are worth the effort even if you never split. Most of the benefit people attribute to services comes from having drawn the lines, not from having deployed them separately.
Summary
- A module owns a business capability end to end and exposes a small contract
- Boundaries inside one deployable give most of the benefit without distributed costs
- Separate projects make internal access a compiler rule rather than a convention
- Modules must own their tables; shared tables or shared entities mean shared fate
- If a split is ever needed, the seams already exist and the transport is what changes
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Where does the line go?
An application handles orders, invoices, payments, customers, employees and reporting. Two modules are proposed: Sales (orders, invoices, payments, customers) and People (employees).
What would you want to know before agreeing, and what would make you draw the lines differently?
Show solution
Ask which changes tend to arrive together. Boundaries pay off when a typical request touches one module. If pricing changes always touch orders and invoices, keeping them together is reasonable; if invoicing changes for finance reasons on a different schedule, that is a seam.
Ask who owns each area. A module with two owners who disagree will end up with a contract that serves neither.
Reporting is the interesting one. It reads across everything, so putting it inside Sales gives Sales a reason to know about employees. A separate reporting module that consumes published data — or a read model fed by events — keeps the other boundaries intact.
Customers is the other pressure point. Sales cares about billing addresses and credit terms; People cares about nothing here. If a third module later needs a different view of a customer, resist one shared Customer entity: each module keeping the fields it needs is what stops the boundaries collapsing.
There is no single correct split, and the first one you draw will be adjusted. Cheap to adjust inside one deployable is exactly the advantage being used here.
Challenge
Enforce one boundary
Take one capability in an application you maintain and give it a real boundary: its own project, its own DbContext covering only its tables, internal types except for one contract, and a single registration method.
Note every place that broke while you were doing it.
Show solution
The breakages are the useful output. Each compiler error is a dependency that existed but was not visible before, and the list tells you how tangled the capability actually was.
Shared entity classes are usually the largest source. The fix is a copy shaped for this module's needs, which feels wrong and is right: two modules with different reasons to change should not share a class.
Queries that join across the new boundary are next. Each one becomes either a method on the contract, or a case for the other module publishing data that this one keeps its own copy of.
If the boundary cannot be enforced without dozens of contract methods, that is evidence the line is in the wrong place. A contract with thirty methods is not a boundary; it is the whole module exposed with extra steps.
Saved in this browser only.