Skip to main content
ANVISoftware Solutions
Lesson 7 of 19Advanced20 min

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.
One module: a narrow public contract, everything else hidden
C#
// 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 deployableSeparate services
Calling another capabilityA method call through an interface; it succeeds or throwsA network call that can also time out, half-succeed or be retried
Changing two capabilities togetherOne commit, one deployment, one transaction if neededCoordinated releases, versioned contracts, no shared transaction
DeploymentOne pipelineOne pipeline per service, plus configuration and secrets for each
Debugging a flowOne stack trace, one debuggerCorrelated logs across processes
Team independenceShared release scheduleEach team releases on its own schedule
ScalingMore instances of the whole applicationMore 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.