The Database Splits Before the App Does
One Postgres database, one schema per module, and entity configurations that are all empty bodies - what ModularMonolith's data layer declares about future service boundaries, and what its migrations quietly gave away.
Data coupling outlives every other kind. You can untangle project references in an afternoon and message contracts in a sprint, but two modules joined across each other's tables are one module wearing two names, and no amount of folder discipline changes it. So the most forward-looking decision in the DevMentors ModularMonolith estate is also its least glamorous: every module gets its own Postgres schema, in the same database, from the first migration onward. This part reads the data layer — the schema split, the DbContexts, and the configuration files whose emptiness turns out to be the loudest thing in them.
One database, three territories
Each module's DbContext claims its territory in one line:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("conferences");
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
ConferencesDbContext owns schema conferences (tables Conferences, Hosts); SpeakersDbContext owns speakers (table Speakers). Tickets, true to its stub nature, has no DbContext at all — its schema will exist when the module does. All of it lands in the single database named in appsettings.json, modular-monolith, so there is one connection string, one docker container, one backup — and three logical territories that never touch.
This is the same forward-compatibility move as the URL prefixes of part 2: schema-per-module is database-per-service with the extraction deferred. When a module leaves the monolith (part 14), its data does not need untangling from anyone else's — pg_dump --schema=speakers, restore, repoint a connection string. And because no EF model can see another module's tables, cross-module joins are not merely discouraged; they are unexpressible in the ORM layer. The database enforces a boundary the application only promises. Each context also gets its own migration history within its schema, so modules version their storage independently — genuinely the hard part of database-per-service, rehearsed early.
Honesty requires the ceiling stated too: schemas in one database share a server, a connection pool, locks, and a failure domain. Nothing stops raw SQL from joining conferences.Conferences to speakers.Speakers, and one runaway module query still starves the others. The split is real at the modelling layer and social at the operational layer.
The empty configuration bodies
Both modules follow the polished convention of configuration-per-entity — ApplyConfigurationsFromAssembly picks up dedicated IEntityTypeConfiguration<T> classes. Then you open one:
public class ConferenceConfiguration : IEntityTypeConfiguration<Conference>
{
public void Configure(EntityTypeBuilder<Conference> builder)
{
}
}
Empty. So are HostConfiguration and SpeakerConfiguration — all three, empty bodies since the day they were committed. The scaffolding of a well-configured model with none of the configuration, and the migration files show exactly what that omission bought. From 20210414193516_Conferences_Module_Init.cs:
Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Location = table.Column<string>(type: "text", nullable: true),
From = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
Read the schema like a reviewer:
- Every string is nullable, unbounded
text. Meanwhile the DTO layer declares[Required]and[StringLength(100, MinimumLength = 3)]on the very same conceptual fields. The rules exist — at the HTTP border only. Any code path that bypasses the DTOs (a migration script, a future bulk import, another handler) can write a null name the API layer swears is impossible. The database believes none of what the application promises. - Timestamps without time zone. Conference
From/Tostore whatever wall-clock arrived, with the era-typical ambiguity that net5.0-and-earlier Npgsql tolerated; Npgsql 6 later forced this issue estate-wide for everyone. HostIdhas no foreign key, no index, no navigation property. Auuidcolumn, nothing more.
That last row deserves its own verdict, because it is genuinely ambiguous. Id-only references between aggregates — Conference holds HostId, never a Host object — is a defensible DDD posture, the same discipline that keeps aggregates small and is mandatory across service boundaries. But both entities live in the same module and schema, where an FK would cost nothing and would catch orphaned references the application currently permits: ConferenceService.AddAsync checks the host exists before inserting, and nothing prevents deleting that host afterwards, leaving conferences pointing at a ghost. And with no index on HostId, any future browse-by-host scans the table. Posture or omission? The empty configuration files make it impossible to tell — which is itself the finding: an undeclared discipline is indistinguishable from a gap. One line in the configuration body (builder.HasIndex(x => x.HostId), or a comment declaring the no-FK stance) would have signed the decision.
Migrate on start, in the wrong room
The remaining data-layer choice was read in part 4 but belongs in this inventory: AddPostgres<T> runs Database.Migrate() inside ConfigureServices, per module, at every boot. For a sample this is the right default badly placed — clone, compose up, F5, and the schemas simply exist; no dotnet ef database update incantation to document (in a repo with no README to document it in). The costs, to recap: startup hard-fails before the host can supervise anything if Postgres is down, and each module builds a throwaway service provider to reach its context. Today you would hold the migration in a hosted service gated before readiness — same developer experience, supervised failure.
What the data layer teaches
The pattern across all three findings is asymmetry of effort: the structural decisions (schema-per-module, per-module migrations, configuration-per-entity scaffolding) are excellent and deliberate, while the content decisions (column constraints, keys, indexes) are absent entirely. It is architecture-first modelling — boundaries before invariants — and it produces a database that is perfectly partitioned and internally credulous. The durable rule: your schema is the only layer that enforces rules when every application layer is bypassed, and it is the layer this estate left blank. DTO validation evaporates at the first non-HTTP write; the schema is forever.
The same asymmetry shows up one layer higher, where the repository pattern is implemented twice per aggregate — once in memory, once in EF — and the DI container quietly decides which one wins. That graveyard of registrations is next.