"The best of both worlds — the simplicity of a monolith with the discipline of microservices."
A practical guide to building a well-structured modular monolith for a 40-person engineering team using Angular and .NET Core. Covers logical domain splitting, boundary enforcement, inter-module communication, and a clear-eyed comparison with microservices on cost and scaling.
A modular monolith is a single deployable unit that is internally divided into strongly isolated, independently-reasoned modules. It runs as one process but enforces domain boundaries at the code level — preventing the "big ball of mud" that typical monoliths degenerate into.
Think of it as an apartment building: one structure, shared utilities (electricity, water), but each apartment is completely self-contained with its own entrance, rooms, and rules. Tenants don't walk through each other's kitchens.
Classic Monolith
One codebase, no internal boundaries. Everything talks to everything. Works at small scale, turns into spaghetti as teams grow. A change in billing accidentally breaks shipping logic.
Modular Monolith ★
One deployable, strict internal module contracts. Teams own their domain. Cross-domain calls go through defined interfaces only. Refactoring is safe; blast radius is bounded.
Microservices
Separate processes, separate deployments, separate databases. Maximum isolation but enormous operational overhead — Kubernetes, distributed tracing, service mesh, inter-service auth, network latency.
▸
For a 40-person team: Microservices' overhead (DevOps complexity, distributed debugging, network failure modes) typically requires a dedicated platform engineering team of 8–12 just to keep the infrastructure healthy. A modular monolith lets your 40 engineers focus on product, not plumbing.
02
Why Choose This Architecture?
// TRADEOFFS FOR YOUR TEAM SIZE
The right architecture depends on team topology, product maturity, and operational capacity. A 40-person engineering team building a growing SaaS product sits in the sweet spot where a modular monolith delivers maximum productivity with acceptable tradeoffs.
8 engineers own the Product & Catalog module end-to-end: Angular feature, .NET module, DB schema.
Commerce Team
8 engineers own Ordering, Cart, Payments. Clear input/output contracts with other modules.
Identity Team
6 engineers own Auth, Users, Permissions. Exposes a contract — other modules never touch user tables directly.
Platform Team
8 engineers own shared kernel, CI/CD, observability, Shared DB migrations framework.
Remaining ~10 engineers split across Notifications, Reporting, and Admin modules.
03
Running Example: E-Commerce Platform
// CONCRETE DOMAIN MODEL
We'll use an e-commerce platform throughout this handbook. The system has six domains — each maps to a module with its own codebase boundary, database schema, and Angular feature module.
📦
Catalog
Products, categories, search, inventory levels
🛒
Ordering
Cart, checkout, order lifecycle, returns
💳
Payments
Transactions, refunds, payment methods, fraud
🔐
Identity
Users, auth, roles, sessions, social login
🔔
Notifications
Email, SMS, push, templates, preferences
📊
Reporting
Revenue, analytics, exports, dashboards
ℹ
Key rule: When an order is placed, the Ordering module does not call NotificationService.SendEmail() directly. It raises a domain event: OrderPlaced. The Notifications module listens and reacts. This is the boundary in action — zero direct dependency between Ordering and Notifications.
04
Domain-Based vs Module-Based Split
// CHOOSING YOUR BOUNDARY STRATEGY
There are two main philosophies for splitting a modular monolith. Choose based on how your business logic is organized, not your technical layers.
📁 Module-Based Split (Technical)
Split by technical concern: Controllers, Services, Repos, Models
Easy to start — maps to how most devs think initially
Cross-cutting features touch many folders simultaneously
Teams still step on each other — no business ownership
Does NOT produce a modular monolith — just a layered one
★ Domain-Based Split (Recommended)
Split by business domain: Catalog, Ordering, Payments, Identity
E.g.: /Modules/Ordering/ contains its own controllers, services, repos
Each module is a vertical slice — owns all its layers
A team works in one folder for any feature end-to-end
Natural mapping to microservice extraction later
Aligns with DDD Bounded Contexts
⚠
Common mistake: Splitting by technical layer (Controllers/, Services/, Repositories/) is not a modular monolith. It's a layered monolith — all the pain, none of the gain. Every feature still touches every layer and every team's code. Always split by domain first, then apply layers within each domain.
How to Identify Domain Boundaries
Ubiquitous Language
When the word "product" means something different to Catalog (SKU, description, price) vs. Ordering (ordered item, quantity) vs. Reporting (revenue line) — you have separate bounded contexts. Each context owns its own definition.
Conway's Law
Structure your modules around how your teams are organized. If 8 people work on commerce features together, that's one module. Don't fight Conway's Law — design with it. Team boundaries and module boundaries should match.
Change Frequency
Things that change together belong together. Catalog and Inventory change together (product updates). Payments and Fraud change together. If two components always change in sync, they're in the same module.
05
.NET Core Directory Structure
// SOLUTION LAYOUT & PROJECT REFERENCES
The .NET solution uses one project per module with strict reference rules — modules can only reference the Shared Kernel and their own internals. No module references another module's implementation.
// CatalogModule.cs — each module self-registerspublic classCatalogModule : IModuleStartup
{
public voidConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddDbContext<CatalogDbContext>(opts =>
opts.UseSqlServer(config.GetConnectionString("Default"),
sql => sql.MigrationsHistoryTable("__EFMigrationsHistory_Catalog")));
// ↑ module-scoped migration table — doesn't clash with other modules
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<ICatalogApi, CatalogService>();
// ↑ ICatalogApi is the public face — only this interface crosses module borders
}
}
// Program.cs — host just wires modulesvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddModule<CatalogModule>(builder.Configuration);
builder.Services.AddModule<OrderingModule>(builder.Configuration);
builder.Services.AddModule<IdentityModule>(builder.Configuration);
builder.Services.AddModule<PaymentsModule>(builder.Configuration);
builder.Services.AddModule<NotificationsModule>(builder.Configuration);
var app = builder.Build();
app.MapModuleEndpoints(); // discovers all module controllers automatically
app.Run();
Database Schema Isolation
C# — Per-Module DbContext with Schema
// CatalogDbContext.cs — module owns its own schemapublic classCatalogDbContext : DbContext
{
publicDbSet<Product> Products => Set<Product>();
publicDbSet<Category> Categories => Set<Category>();
protected override voidOnModelCreating(ModelBuilder modelBuilder)
{
// All catalog tables live under the "catalog" SQL schema
modelBuilder.HasDefaultSchema("catalog");
modelBuilder.Entity<Product>()
.ToTable("products") // → catalog.products
.HasKey(p => p.Id);
}
}
// Result: SQL Server has schemas per module// catalog.products ← owned by Catalog module ONLY// catalog.categories ← owned by Catalog module ONLY// ordering.orders ← owned by Ordering module ONLY// ordering.order_lines ← owned by Ordering module ONLY// payments.transactions ← owned by Payments module ONLY// identity.users ← owned by Identity module ONLY//// RULE: No module may issue SQL (or EF queries) against another module's schema.// Ordering needs user data? It calls ICatalogApi, not catalog.products directly.
06
Angular Directory Structure
// FEATURE MODULES & LAZY LOADING
The Angular frontend mirrors the backend domain split. Each backend module has a corresponding Angular feature module with its own routing, components, services, and state. Lazy loading keeps the initial bundle small and makes domain isolation visible at the framework level.
// app.routes.ts — top-level routes with lazy loadingexport constroutes: Routes = [
{
path: 'catalog',
// Catalog module is a separate JS chunk — not loaded until user navigates here
loadChildren: () => import('./modules/catalog/catalog.routes')
.then(m => m.CATALOG_ROUTES),
canActivate: [AuthGuard]
},
{
path: 'orders',
loadChildren: () => import('./modules/ordering/ordering.routes')
.then(m => m.ORDERING_ROUTES),
canActivate: [AuthGuard]
},
{
path: 'reports',
loadChildren: () => import('./modules/reporting/reporting.routes')
.then(m => m.REPORTING_ROUTES),
canActivate: [AuthGuard, RoleGuard],
data: { roles: ['admin', 'analyst'] }
},
];
// catalog.routes.ts — Catalog's own child routesexport constCATALOG_ROUTES: Routes = [
{ path: '', component: CatalogPageComponent },
{ path: ':id', component: ProductDetailComponent },
{ path: 'new', component: CreateProductComponent, canActivate: [RoleGuard] },
];
// RULE: CatalogService ONLY calls /api/catalog/* endpoints// It never calls /api/ordering or /api/identity directly// Cross-module data needs come through the shared store or backend events
07
Enforcing Module Boundaries
// HARD RULES, TOOLING, CODE REVIEW
Boundaries only work if they're enforced mechanically — not just by convention or honor system. With a 40-person team you will have turnover, junior developers, and deadline pressure. The tooling must make violations impossible or immediately visible.
Backend Enforcement (.NET)
C# — ArchUnitNET Architecture Tests
// ArchitectureTests.cs — runs in CI, fails the build on violationsusing ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.Fluent;
[TestClass]
public classModuleBoundaryTests
{
private static readonlyArchitecture Architecture =
newArchLoader().LoadAssemblies(
typeof(CatalogModule).Assembly,
typeof(OrderingModule).Assembly,
typeof(PaymentsModule).Assembly
).Build();
[TestMethod]
public voidOrdering_Must_Not_Depend_On_Catalog_Implementation()
{
// Ordering CAN reference ICatalogApi (the contract)// Ordering CANNOT reference anything inside Catalog's Application/Domain/InfrastructureClasses().That().ResideInNamespace("ECommerce.Modules.Ordering")
.Should().NotDependOnAny(
Classes().That().ResideInNamespace("ECommerce.Modules.Catalog.Application")
.Or().ResideInNamespace("ECommerce.Modules.Catalog.Domain")
.Or().ResideInNamespace("ECommerce.Modules.Catalog.Infrastructure")
).Check(Architecture);
}
[TestMethod]
public voidNo_Module_May_Access_Another_Modules_DbContext()
{
// Ordering must never inject CatalogDbContext — architectural violationClasses().That().ResideInNamespace("ECommerce.Modules.Ordering")
.Should().NotDependOnAny(
Classes().That().HaveNameEndingWith("DbContext")
.And().DoNotResideInNamespace("ECommerce.Modules.Ordering")
).Check(Architecture);
}
}
Frontend Enforcement (Angular)
JSON — nx.json / ESLint Module Boundaries
// .eslintrc.json — enforce import boundaries with @nx/enforce-module-boundaries
{
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{
"sourceTag": "module:catalog",
"onlyDependOnLibsWithTags": ["scope:shared"]
// Catalog components CANNOT import from ordering/, payments/ etc.
},
{
"sourceTag": "module:ordering",
"onlyDependOnLibsWithTags": ["scope:shared"]
// Ordering components CANNOT import from catalog/, payments/ etc.
},
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
// Shared lib must not depend on any module
}
]
}]
}
}
// Trying to do this in ordering.component.ts will FAIL the lint step in CI:// import { ProductService } from '../catalog/services/catalog.service'; // ❌ BLOCKED// import { Button } from '../../shared/components/button'; // ✅ OK
▸
PR checklist enforcement: Add a GitHub Actions step that runs dotnet test --filter Category=Architecture (ArchUnitNET) and nx run-many --target=lint. A PR with a boundary violation never merges. This removes the cognitive burden from reviewers and makes the architecture self-enforcing.
08
Module Communication Patterns
// IN-PROCESS EVENTS, CONTRACTS, SYNC CALLS
Modules must communicate without tightly coupling. There are two mechanisms: synchronous contract calls (one module calls another's public interface) and in-process domain events (fire-and-forget, publisher doesn't know the subscriber).
Pattern 1 — Synchronous Contract Call
Use when: Ordering needs a product's current price at checkout time. It needs the answer immediately and synchronously.
Caller
Ordering Module
→
Interface
ICatalogApi
→
Impl
CatalogService
→
Data
catalog.products
C# — Public Contract Interface
// Contracts/ICatalogApi.cs — this is the ONLY public surface of the Catalog modulenamespace ECommerce.Modules.Catalog.Contracts;
public interfaceICatalogApi
{
// Ordering calls this to validate items before placing an order
Task<ProductDetailsDto> GetProductAsync(Guid productId);
Task<bool> IsInStockAsync(Guid productId, int quantity);
Task DecrementStockAsync(Guid productId, int quantity);
}
public recordProductDetailsDto(Guid Id, string Name, decimal Price, string ImageUrl);
// ↑ DTO, not the domain entity. Never expose your entity across boundaries.// Ordering/Application/Commands/PlaceOrderCommand.cspublic classPlaceOrderCommandHandler
{
private readonlyICatalogApi _catalog; // injected — interface, not concrete classpublic async Task Handle(PlaceOrderCommand cmd)
{
var product = await _catalog.GetProductAsync(cmd.ProductId);
if (product == null) throw newProductNotFoundException(cmd.ProductId);
var inStock = await _catalog.IsInStockAsync(cmd.ProductId, cmd.Quantity);
if (!inStock) throw newInsufficientStockException();
// Create order, persist it...await _catalog.DecrementStockAsync(cmd.ProductId, cmd.Quantity);
}
}
Pattern 2 — Domain Events (In-Process)
Use when: After an order is placed, send a confirmation email and update reporting. The Ordering module should not know or care who reacts.
C# — In-Process Event Bus
// SharedKernel/Events/OrderPlacedEvent.cs — shared contract, not implementationpublic recordOrderPlacedEvent(Guid OrderId, Guid UserId, decimal Total, DateTime PlacedAt)
: IDomainEvent;
// Ordering module PUBLISHES the event — knows nothing about subscriberspublic classPlaceOrderCommandHandler
{
private readonlyIEventBus _bus;
public async Task Handle(PlaceOrderCommand cmd)
{
// ... business logic, persist order ...await _bus.PublishAsync(newOrderPlacedEvent(orderId, cmd.UserId, total, DateTime.UtcNow));
// Ordering is done — no coupling to Notifications or Reporting
}
}
// Notifications module SUBSCRIBES — knows nothing about Ordering internalspublic classSendOrderConfirmationEmailHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent evt)
{
// Send confirmation email for evt.OrderId to evt.UserIdawait _emailService.SendOrderConfirmationAsync(evt.UserId, evt.OrderId);
}
}
// Reporting module ALSO subscribes to the same eventpublic classUpdateRevenueReportHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent evt)
{
await _reportRepo.RecordSaleAsync(evt.Total, evt.PlacedAt);
}
}
ℹ
In-process vs. message queue: Start with an in-process event bus (MediatR, a custom IEventBus, or Wolverine). This is synchronous, transactional (same DB transaction), and requires zero infrastructure. Later, if you extract a module to a microservice, you swap the in-process bus for RabbitMQ or Azure Service Bus — the code barely changes because the event contracts are already defined.
09
Shared Kernel — What Belongs There?
// THE DANGEROUS MIDDLE GROUND
The Shared Kernel is a strictly curated set of abstractions and utilities used by all modules. It must not contain business logic. It must not grow into a "utils dumping ground." Every addition should be challenged with: "does this truly belong to no domain?"
Angular: UI design system components (Button, Modal, Table)
Angular: AuthGuard, HttpInterceptors, base error handler
✕ Never Put in Shared Kernel Forbidden
Business logic of any kind
Database entities that belong to a specific domain
Service implementations (only interfaces here)
Domain-specific constants or enums (e.g., OrderStatus)
Cross-module aggregations or queries (these belong in Reporting)
Angular: Module-specific components, routes, or state
Anything that requires importing from a specific module
⚠
The Shared Kernel death spiral: Every time something is added to the Shared Kernel "for convenience," it becomes a hidden coupling between modules. After 2 years without discipline, the Shared Kernel becomes a second big ball of mud and all your boundary work is undone. Review additions to the Shared Kernel in architecture review — not just standard PR review.
10
Scaling Strategy
// HOW A MODULAR MONOLITH SCALES
The most common objection to a modular monolith is "it can't scale." This is incorrect. It scales horizontally just like microservices — you run multiple instances behind a load balancer. The difference is you scale the entire application, not individual services.
Horizontal Scaling — Run Multiple Instances
Users
Traffic
→
Infra
Load Balancer
→
Instance 1
Full App
+
Instance 2
Full App
+
Instance N
Full App
◈
The real bottleneck is almost always the database, not the app tier. A well-indexed SQL Server or Postgres can handle tens of thousands of transactions per second. For most companies at 40-engineer scale, horizontal app scaling + read replicas + Redis caching is more than sufficient. You reach the database ceiling long before you need per-module compute isolation.
Scaling Tactics Available to a Modular Monolith
Problem
Solution
Requires Microservices?
High request volume
Horizontal scaling — run 3–10 app instances, sticky sessions not needed if stateless
NO
Read-heavy catalog queries
Read replicas for CatalogDbContext, Redis cache for product data
NO
Reporting slows down OLTP
Separate read model for Reporting module (CQRS), own connection string pointing to replica
NO
Background job load
Move heavy jobs (reports, exports, email batches) to a separate Worker Service project that shares the same codebase
NO
One module truly needs isolated scale
Extract that specific module to a microservice — possible because boundaries are already clean
THEN, YES
Global multi-region
Deploy full app to multiple regions with geo-routing and replicated database
NO
Vertical Slice Extraction — When You're Ready
Because your modular monolith has clean domain boundaries, extracting a module as a microservice is a structured task, not an emergency surgery. The Payments module, for example, can be extracted in a 2-week sprint because its contracts are already defined, its database schema is isolated, and its events are already published via an event bus interface.
11
Cost vs Microservices
// REAL NUMBERS, HONEST TRADEOFFS
Microservices are not free. The operational complexity they require has a direct cost in infrastructure spend, engineering hours, and cognitive load. Below is an honest comparison for a 40-person team running a production SaaS product at medium scale.
Partial — scale whole app + read replicas for hot modules
Full — scale each service independently; worth it at high load
Tech diversity
Limited — same runtime; different libraries per module
Full — different languages per service if needed
Team independence
Good — module ownership, shared CI/CD
Best — fully independent deploy, full team autonomy
Right for 40 engineers?
✓ Yes — strong fit
✗ Usually premature — overhead too high
▸
The Amazon mistake: The famous Amazon/Netflix microservices journey is constantly cited but rarely contextualized. Amazon had hundreds of engineers before they decomposed. Netflix had massive, asymmetric scaling needs (video streaming CDN vs. metadata API). If your entire product serves under 100M requests/day, the modular monolith is almost certainly the right call.
12
Migration Path
// FROM MESSY MONOLITH OR TOWARD MICROSERVICES
A modular monolith is a destination in itself but also the best preparation for microservices if you ever need them. Jumping directly from a messy monolith to microservices is one of the most common and costly architectural mistakes in the industry.
Stage 01
Big Ball of Mud
No internal structure
Everything references everything
Fear-driven development
2-week regressions for small changes
No team ownership
Stage 02
Modular Monolith ★
Domain-based modules
Enforced boundaries
Team ownership per module
Independent test suites
Clean event contracts
Horizontal scaling works
Stage 03
Selective Microservices
Extract 1–2 hot modules if needed
Core stays as modular monolith
Event bus becomes message queue
Only for justified scale needs
Most teams never need this
Migrating from a Legacy Monolith — The Strangler Fig
Strategy — Strangler Fig Migration
## STEP 1 — Identify domain boundaries (4–6 weeks)
→ Event storming workshop with product + engineering
→ Draw context map: who owns what, what data is duplicated
→ Identify "seams" — where the app can be cut with least pain
## STEP 2 — Create module structure alongside existing code (2–4 weeks)
→ Add Modules/ folder, SharedKernel project
→ Don't move anything yet — just create the scaffolding
→ Establish CI rules: ArchUnitNET tests, linting boundaries
## STEP 3 — Module by module extraction (2–4 weeks each)
→ Start with the MOST isolated module (e.g., Notifications — few dependencies)
→ Move code into the module: controllers, services, repositories
→ Create module's own DbContext/schema (data migration in same DB, new schema)
→ All old code still works via a thin adapter during transition
## STEP 4 — Enforce communication patterns (as you go)
→ Replace direct calls between modules with ICatalogApi interfaces
→ Replace direct DB cross-queries with in-process events
→ Write architecture tests after each module is "clean"
## STEP 5 — Remove the old code (as each module completes)
→ Delete the old Controllers/, Services/, Repositories/ files for migrated domains
→ All traffic now goes through the module's own entry points
→ Celebrate — you now have a seam
## Timeline for a 40-person team on medium codebase
→ 6 modules × 3–4 weeks each = ~6 months total
→ Can be parallelized: 3 teams × 2 modules each = ~3 months
→ Keep shipping features throughout — strangler fig is live migration
▸
Reference reading: Sam Newman's Building Microservices and Monolith to Microservices are the canonical texts. The modular monolith pattern is explicitly recommended as the intermediate step. Also see Shopify's engineering blog on their modular Rails monolith — a 40M-user system that remains a disciplined monolith today.