Module Structure and Enforcing Boundaries in a Modular Monolith
Structure and enforce module boundaries in a modular monolith: folder conventions, namespace enforcement, module APIs, dependency analysis with NDepend or ArchUnitNET, and preventing cross-module coupling.
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
// 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 IPatientQueryServicePreventing Cross-Module Coupling with ArchUnitNET
// 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
// 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
-- 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 queriesProduction issue I've seen: A team's "modular monolith" had a shared
DbContextwith 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-specificDbContextclasses (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) notPatient(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.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.