Learnixo

Modular Monolith in .NET · Lesson 2 of 6

Module Structure and Enforcing Boundaries

Module Structure

A modular monolith: one process, multiple modules with enforced boundaries.
Each module is an isolated bounded context within one deployable unit.

Folder structure:
  src/
    Clinical.sln
    Modules/
      Patients/
        Patients.Api/          → public API: endpoints, public DTOs
        Patients.Application/  → use cases (commands/queries/handlers)
        Patients.Domain/       → entities, value objects, domain events
        Patients.Infrastructure/ → EF Core, repositories
      Prescriptions/
        Prescriptions.Api/
        Prescriptions.Application/
        Prescriptions.Domain/
        Prescriptions.Infrastructure/
      LabResults/
        LabResults.Api/
        ...
    Shared/
      SharedKernel/            → Result, Error, Entity base, IDomainEvent
    Host/
      SystemForge.Api/         → Program.cs, module registration, middleware

Module Public API Contract

C#
// Each module exposes only what other modules need — nothing else
// Patients.Api: the public interface of the Patients module

namespace Patients.Api;

// Public: other modules use this to query patient data
public interface IPatientQueryService
{
    Task<PatientSummary?> GetByIdAsync(Guid patientId, CancellationToken ct = default);
}

// Public DTO — no domain types leak across module boundaries
public sealed record PatientSummary(
    Guid   PatientId,
    string Mrn,
    string FullName,
    Guid?  WardId);

// Everything in Patients.Domain, Patients.Infrastructure is INTERNAL
// Other modules cannot access Patient entity directly — only PatientSummary via IPatientQueryService

Preventing Cross-Module Coupling with ArchUnitNET

C#
// ArchUnitNET: enforce architecture rules as tests
// NuGet: ArchUnitNET.xUnit

public sealed class ArchitectureTests
{
    private static readonly Architecture Architecture =
        new ArchLoader().LoadNamespacesWithinAssembly(
            typeof(Program).Assembly).Build();

    [Fact]
    public void PrescriptionsModule_ShouldNotDependOn_PatientsDomain()
    {
        // Prescriptions can use Patients.Api (public interface)
        // but MUST NOT depend on Patients.Domain (internal implementation)
        Classes()
            .That().ResideInNamespace("Prescriptions.*")
            .Should().NotDependOnAnyClassesThat()
            .ResideInNamespace("Patients.Domain.*")
            .Check(Architecture);
    }

    [Fact]
    public void DomainLayer_ShouldNotDependOn_Infrastructure()
    {
        Classes()
            .That().ResideInNamespace("*.Domain.*")
            .Should().NotDependOnAnyClassesThat()
            .ResideInNamespace("*.Infrastructure.*")
            .Check(Architecture);
    }
}

Module Registration Pattern

C#
// Each module registers its own services via an extension method
// Host project composes all modules

// Patients.Infrastructure/ServiceCollectionExtensions.cs
public static class PatientsModuleExtensions
{
    public static IServiceCollection AddPatientsModule(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<PatientsDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Patients")));

        services.AddScoped<IPatientRepository, PatientRepository>();
        services.AddScoped<IPatientQueryService, PatientQueryService>();

        return services;
    }
}

// Host/Program.cs
builder.Services.AddPatientsModule(builder.Configuration);
builder.Services.AddPrescriptionsModule(builder.Configuration);
builder.Services.AddLabResultsModule(builder.Configuration);

Schema-per-Module Database Strategy

SQL
-- Each module gets its own database schema within the same SQL Server database
-- (Or a separate database entirely for stronger isolation)

-- Patients module schema
CREATE SCHEMA patients;
CREATE TABLE patients.patients (id UNIQUEIDENTIFIER PRIMARY KEY, mrn NVARCHAR(20), ...);
CREATE TABLE patients.admissions (id UNIQUEIDENTIFIER PRIMARY KEY, patient_id UNIQUEIDENTIFIER, ...);

-- Prescriptions module schema
CREATE SCHEMA prescriptions;
CREATE TABLE prescriptions.prescriptions (id UNIQUEIDENTIFIER PRIMARY KEY, patient_id UNIQUEIDENTIFIER, ...);

-- Cross-schema join: allowed within the same physical database, but discouraged
-- Use inter-module communication (API or event) instead of cross-schema queries

Production issue I've seen: A team's "modular monolith" had a shared DbContext with all entities from all modules. The Prescriptions developer added a LINQ query that accidentally included .Include(p => p.Patient.Ward.Hospital) — loading Patient, Ward, and Hospital data just to display a prescription list. No module boundary prevented this — it was "just available." Adding module-specific DbContext classes (one per module, each mapping only its own tables) would have made this cross-module data access impossible at compile time.


Key Takeaway

Each module exposes only a public API interface — internal domain and infrastructure types are inaccessible to other modules. Use IPatientQueryService (interface) not Patient (entity) across module boundaries. Enforce boundaries with ArchUnitNET architecture tests that fail the build when violations occur. Each module owns its own database schema and DbContext. Module composition happens in the host project, not in the modules themselves.