- Published on
The Forces on a Modular Monolith's Boundaries: Who Writes the Data, Who Owns the Route, How Wide to Open the Permissions
- Authors

- Name
- Jack Qin
The modular monolith, as an architecture, rests its entire value on a single line: the boundary between modules. Once that boundary is quietly punched through, it degrades into "a big ball of mud with folders" — you think the modules are independent, you change one thing and ripples spread, and nothing in the compiler tells you the boundary is gone.
What I want to take apart here isn't "what are the conventions" but what kind of force this boundary bears at three places: who writes the data, who owns the route, and how wide to open the permissions. The first two are intuitive. The third is the most hidden — when a module that doesn't own some data needs a small slice of someone else's, the most natural approach is precisely the one that opens permissions too wide. Get the forces at these three places straight, and the boundary will hold.
Data: owned by module, written by EF Core, read by Dapper
Backend persistence follows an owned-by-module model:
- Each module owns its own
DbContext. - EF Core is the primary write and aggregate-persistence mechanism.
- Dapper is allowed for read-only query paths that are hard to express cleanly in EF Core.
- Migrations live under each module's
Infrastructure/Persistence/Migrations/. - There is no generic repository layer.
That last one — "no generic repository" — is often treated as a quirk, but it bears directly on the forces at the boundary. A generic repository that spans all modules is essentially an invisible write-access channel wired between every module: module A can write module B's tables through it, and that channel appears in no using statement, is invisible in review, and may not be caught by architecture tests either. A generic repository isn't convenience — it's a tool for quietly dismantling the boundary. So the design choice here is to repeat a bit of CRUD rather than open that channel.
DbContext ownership: each module registers its own DbContext in its own ModuleRegistration.cs. Entity configuration and migrations stay inside the module that owns them. Don't make one module depend on another module's DbContext, and don't introduce cross-module write access through a shared repository.
How to choose between EF Core and Dapper — this isn't a "which is faster" question, it's a "is this operation fundamentally a write or a read" question:
| Use EF Core | Use Dapper |
|---|---|
| Ordinary CRUD | Complex reporting and list queries |
| Aggregate persistence | Read-only query services |
| Transactional writes | SQL-heavy endpoints where writing SQL directly is clearer than forcing EF |
| Entity loading and updates within a module |
Dapper's rules: stay in the query/read-model layer or a similarly obvious read-only location; don't scatter raw SQL through unrelated application code; and don't write with Dapper unless there's a very strong, documented reason. Funneling writes into EF Core is what gives "who changed the state" a single, traceable entry point — scattered ExecuteAsync("UPDATE ...") calls leave state changes with nowhere to be found.
Migration flow (following the repo-root process):
- Update the entity model in the module that owns it
- Generate the migration from
apps/api/ - Review the generated migration
- Apply it with
dotnet ef database update - Update any affected Dapper queries or contracts
One easily-overlooked host behavior: the API host automatically applies pending migrations on startup, unless Testing:SkipMigrations is set. That means a schema change has two landing scenarios to think about at once — the automatic migration on production startup, and the table-creation logic in the test host. Host integration tests use Testcontainers, explicitly create tables, and disable the host's migration loop. Think only about production and forget the test host, and the schema change will blow up in CI in unexpected ways.
Routing: Minimal API, organized by module, endpoints kept thin
The backend uses Minimal API organized by module:
- Each module exposes an endpoint-mapping extension method under
Endpoints/. - Module registration and endpoint mapping are wired through
ModuleRegistration.cs. - Routes are grouped under
/api/v1/.... - Authorization is usually required at the group level, with finer permission checks inside the handler.
- Endpoints return status codes and JSON results, not MVC controller responses.
The standard pattern for endpoint shape:
public static IEndpointRouteBuilder MapAssetEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/assets")
.WithTags("Assets")
.RequireAuthorization(); // group-level authorization
group.MapGet("/", /* ... */);
group.MapPost("/", /* ... */);
group.MapPut("/{id}", /* ... */);
group.MapDelete("/{id}", /* ... */);
return app;
}
"Keep endpoints thin" isn't aesthetics — it's an expression of boundary discipline. An endpoint should own: route shape, request binding, permission checks, and translating missing/forbidden/created/ok into HTTP results. An endpoint shouldn't become a dumping ground for cross-module shortcuts, shared persistence abstractions, or host-level bootstrap logic. Once endpoints start getting fat, what slips in first is usually cross-module logic — because the endpoint is where requests converge, and "let me just call another module here while I'm at it" is the most natural temptation. The value of a thin endpoint is that it leaves that temptation nowhere to hide.
Authorization and permissions: authorization starts from a group-level .RequireAuthorization(). Permission checks inside the handler use the current-user abstraction and module permissions, e.g. user.CanAccess(AppModules.X), user.CanAccessSite(siteId), plus admin-only branches where appropriate. Note that there are two layers here — "authenticated" and "authorized to access this site/module" are different things, and group-level authorization only handles the first layer.
The repeatedly-overlooked point: the least-privilege cross-module endpoint
The boundaries in the first two sections are fairly intuitive. What actually trips people up is this class of scenario.
Problem: a module that doesn't own some data (say, mobile heatmap users) needs a narrow slice of another module's data (say, asset coordinates). Here there are two paths that look workable but both open permissions too wide: give the consumer full access to the module — too broad, it only wants coordinates but gets the whole module; or open the data owner's generic endpoint to the consumer's permissions — also too broad, that generic endpoint was designed for the owner, and opening it to the consumer means the consumer sees far more fields than it needs.
Solution: mint a focused endpoint inside the module that owns the data, gated by a permission OR-set that covers exactly the consumers needing this slice. The owner's generic endpoint stays restricted to owner-only permissions.
// Site-scoped, trimmed DTO, permission OR-set: Assets or Heatmap.
group.MapGet("/markers", async (..., Guid siteId, CancellationToken ct) =>
{
if (!user.CanAccess(AppModules.Assets) && !user.CanAccess(AppModules.Heatmap))
return Results.Forbid();
if (!user.CanAccessSite(siteId)) return Results.Forbid();
// ... returns a SensorMarkerDto[] with only marker-relevant fields
});
The generic AssetEndpoints and AssetLocationEndpoints keep their respective Assets-only checks; only the dedicated marker route adds the OR-Heatmap branch. The benefits of this design are worth working through one by one, because each is a concrete fulfillment of the least-privilege principle: the trimmed DTO + dedicated route makes the permission scope obvious at the call site; the generic endpoint stays safe even if a trimmed consumer role later expands; and a reviewer can see the OR-set in one file, without grepping every endpoint to reconstruct the full permission picture.
To be clear, this is not a way to bypass boundary checks — the module that owns the data still owns the data, and the route still lives in its own ModuleRegistration. Its essence is: when fetching data across modules, tighten permissions at the data owner down to exactly enough, rather than loosening them at the consumer into something broad and vague.
Results, errors, and contract discipline
Result pattern: use Results.* consistently — commonly Results.Ok(...), Results.Created(...), Results.NotFound(), Results.Forbid(). API-specific auth behavior: unauthenticated requests return 401, forbidden requests return 403, and login/access-denied does not redirect HTML clients away from API routes. That last one is crucial — an API is consumed by programs, redirect-style auth is designed for browser pages, and mixing it into API routes hands the client a 302 it can't make sense of.
Error handling: use centralized exception handling rather than copy-pasting generic try/catch everywhere. Host behavior: AddExceptionHandler<GlobalExceptionHandler>() + AddProblemDetails(), returning sanitized ProblemDetails JSON from the exception handler.
Contract discipline: return DTOs and contract shapes, not EF entities. If an endpoint change affects the frontend or other consumers, treat it as a contract change and update the related tests and generated client.
Counter-examples
// Counter-example 1: writing directly to another module's tables across the boundary
public async Task Handle(...)
{
await _emailDbContext.Schedules.AddAsync(...); // ❌ this is Assets-module code
}
// Counter-example 2: introducing a Controller for a new feature (the repo uses Minimal API)
[ApiController]
[Route("api/v1/assets")]
public class AssetsController : ControllerBase { /* ❌ */ }
// Counter-example 3: skipping site/module permission once authenticated
group.MapGet("/sites/{siteId}/data", async (Guid siteId, /* ... */) =>
{
// ❌ RequireAuthorization doesn't mean this user can access this site
return Results.Ok(await query.GetSiteData(siteId));
});
// Correct: still need the user.CanAccessSite(siteId) check
// Counter-example 4: writing with Dapper without a strong reason
await connection.ExecuteAsync("UPDATE assets SET name = @name WHERE id = @id", ...); // ❌
// writes should go through EF Core
Other things to avoid: don't add a generic repository abstraction spanning all modules; don't bypass module boundaries to write directly to another module's tables or DbContext; don't force complex reporting queries into EF Core when an existing Dapper pattern is a better fit; don't forget to update Dapper SQL after a schema change; don't add redirect-style auth behavior to API endpoints; don't spread one endpoint's surface across multiple unrelated modules.
Putting it into practice
- Data ownership follows the module: whoever's data you write, do it inside that module, with that module's
DbContext. - Write with EF, read-heavy with Dapper: Dapper stays in the query/read-model layer and is never used to write.
- Keep endpoints thin: HTTP concerns, permissions, route-local persistence orchestration — that's all.
- Use least-privilege endpoints for cross-module data: mint a dedicated route with a trimmed DTO + permission OR-set at the data owner, don't loosen the generic endpoint.
- Think about tests alongside schema changes: the change must pass both the automatic migration on production startup and the explicit table creation in the Testcontainers test host.
- A changed endpoint shape is a contract change: update contract tests and the generated client.
The transferable layer
Strip away the specific APIs of .NET and EF Core, and the genuinely transferable insight of the modular monolith is: a boundary isn't punched through by one big refactor — it's eroded by a string of "while I'm at it" moments. Writing B's tables from A "while I'm at it," opening the generic endpoint to others "while I'm at it," writing once with Dapper "while I'm at it" — each is trivial on its own, but accumulated, the module is no longer a module.
The key to holding boundaries isn't memorizing every rule, it's a habit: every time you're about to cross a module line, stop and ask — who owns this slice of data? Which side did this permission get opened on? Am I tightening at the data owner, or loosening at the consumer? Putting the constraint at the data owner and tightening it down to exactly enough is the common correct answer to nearly all cross-boundary design.