Learnixo
Back to blog
AI Systemsintermediate

Data Isolation Between Modules — Schema-per-Module Strategies

Enforce data isolation between modules in a modular monolith: schema-per-module in SQL Server, separate DbContext per module, preventing cross-schema queries, and managing module-specific migrations.

Asma Hafeez KhanMay 16, 20264 min read
Modular MonolithData IsolationEF CoreArchitecture.NETDatabase
Share:𝕏

Why Data Isolation Matters

Shared database, no isolation:
  Any module can query any table.
  JOIN prescriptions to patients to lab_results to billing in one query.
  A schema change in patients breaks prescriptions, lab, and billing simultaneously.
  All modules must deploy together when any schema changes.

Schema-per-module isolation:
  Each module owns its schema (or database).
  Other modules cannot see or query its tables.
  Schema changes in patients only affect the patients module.
  Modules can deploy independently.

This is how a modular monolith avoids the "big ball of mud" trap.

Schema-per-Module Setup

SQL
-- SQL Server: each module gets its own schema
CREATE SCHEMA patients;
CREATE SCHEMA prescriptions;
CREATE SCHEMA lab_results;
CREATE SCHEMA billing;

-- Patients module tables (only patients module reads/writes these)
CREATE TABLE patients.patients (
    id          UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
    mrn         NVARCHAR(20)     NOT NULL,
    first_name  NVARCHAR(100)    NOT NULL,
    last_name   NVARCHAR(100)    NOT NULL,
    ward_id     UNIQUEIDENTIFIER NULL
);

-- Prescriptions module tables (different schema, same database or different database)
CREATE TABLE prescriptions.prescriptions (
    id            UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
    patient_id    UNIQUEIDENTIFIER NOT NULL,  -- stores PatientId by reference only
    medication    NVARCHAR(200)    NOT NULL,
    dose_amount   DECIMAL(10,4)    NOT NULL
);

-- Cross-schema references stored as IDs only  no FK constraints across schemas
-- (Or use CHECK constraints, but not FOREIGN KEY across schemas/databases)

Separate DbContext per Module

C#
// Patients module DbContext — only maps patients schema
public sealed class PatientsDbContext : DbContext
{
    public PatientsDbContext(DbContextOptions<PatientsDbContext> options)
        : base(options) { }

    public DbSet<Patient>   Patients   => Set<Patient>();
    public DbSet<Admission> Admissions => Set<Admission>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("patients");
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(PatientsDbContext).Assembly);
    }
}

// Prescriptions module DbContext — only maps prescriptions schema
public sealed class PrescriptionsDbContext : DbContext
{
    public DbSet<Prescription> Prescriptions => Set<Prescription>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("prescriptions");
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(PrescriptionsDbContext).Assembly);
    }
}

// Both can point to the same connection string (same SQL Server database, different schemas)
// Or different connection strings (different databases) for stronger isolation

Module-Specific Migrations

C#
// Each module manages its own migrations independently
// Patients module:
dotnet ef migrations add AddPatientWardColumn \
  --project Modules/Patients/Patients.Infrastructure \
  --startup-project Host/SystemForge.Api \
  --context PatientsDbContext \
  --output-dir Persistence/Migrations

// Prescriptions module:
dotnet ef migrations add AddExpiryDate \
  --project Modules/Prescriptions/Prescriptions.Infrastructure \
  --startup-project Host/SystemForge.Api \
  --context PrescriptionsDbContext \
  --output-dir Persistence/Migrations

// Apply at startup — each DbContext applies its own migrations
using (var scope = app.Services.CreateScope())
{
    await scope.ServiceProvider
        .GetRequiredService<PatientsDbContext>()
        .Database.MigrateAsync();

    await scope.ServiceProvider
        .GetRequiredService<PrescriptionsDbContext>()
        .Database.MigrateAsync();
}

Denormalized Read Models for Cross-Module Queries

C#
// Prescriptions module needs to display patient name and ward alongside prescriptions.
// Instead of calling PatientsService (synchronous coupling), store a snapshot.

// Prescriptions module's own "patient snapshot" table
CREATE TABLE prescriptions.patient_snapshots (
    patient_id   UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
    full_name    NVARCHAR(200)    NOT NULL,
    mrn          NVARCHAR(20)     NOT NULL,
    ward_id      UNIQUEIDENTIFIER NULL,
    updated_at   DATETIME2        NOT NULL
);

// Updated via module event subscription
public sealed class UpdatePatientSnapshotOnPatientChanged
    : INotificationHandler<PatientUpdatedModuleEvent>
{
    private readonly PrescriptionsDbContext _db;

    public async Task Handle(PatientUpdatedModuleEvent @event, CancellationToken ct)
    {
        await _db.PatientSnapshots
            .Upsert(new PatientSnapshot(
                @event.PatientId, @event.FullName, @event.Mrn, @event.WardId, DateTime.UtcNow))
            .On(s => s.PatientId)
            .RunAsync(ct);
    }
}

// Queries: JOIN prescriptions.prescriptions with prescriptions.patient_snapshots
// All within one schema — no cross-module data access needed at query time

Production issue I've seen: A "modular monolith" had separate DbContexts per module but shared the same migration project. When the Patients team ran dotnet ef migrations add, it scanned all DbContexts and included all changes in one migration file. The Prescriptions team's schema changes appeared in the Patients migration and vice versa. Migrations were impossible to isolate. The fix was separate migration projects per module — each producing its own migration files, applied independently.


Key Takeaway

Assign each module its own SQL schema. Use a separate DbContext per module mapping only that module's schema. Modules reference each other's data by ID only — no cross-schema JOINs in production queries. Store denormalized snapshots within each module's schema for read-model queries. Manage migrations per module — separate output directories, applied independently at startup.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.