To define module boundaries in a modular monolith, map your business capabilities, group the ones that change together, and align each module with a bounded context from Domain-Driven Design. Then validate with the change test: a typical business change should affect exactly one module. Here is the full process, from context maps to the boundary mistakes that sink most modular monoliths.
Why Boundaries Matter
Draw module boundaries wrong, and you'll spend your time fighting the architecture instead of building features. Modules that are too fine-grained create an explosion of inter-module communication. Modules that are too coarse become monoliths within a monolith.
A bounded context from Domain-Driven Design defines a clear boundary where a particular domain model applies. It's the best tool we have for deciding what goes in each module of a Modular Monolith.
Context Maps
Start by mapping the subdomains of your business:
Each box is a bounded context, and each becomes a module.
Rules for Drawing Boundaries
Rule 1: Each Module Owns Its Language
In the Ordering module, "Product" means an item in the order with a quantity and price. In the Catalog module, "Product" means an item with descriptions, images, and categories. Same word, different meaning.
// Ordering Module
public class OrderProduct
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
// Catalog Module
public class CatalogProduct
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<string> Images { get; set; }
public Guid CategoryId { get; set; }
}
Each module has its own model of the same real-world concept. This is ubiquitous language in action.
Duplicating the Product concept feels wrong at first. It isn't. The Ordering module's OrderProduct is a snapshot of name and price at the time of ordering - you want it frozen even if the catalog changes later. What looks like duplication is actually two different concepts that happen to share a name.
Rule 2: Minimize Cross-Module Communication
If two concepts constantly need each other's data, they probably belong in the same module:
Bad - too chatty between modules
OrdersModule.PlaceOrder → InventoryModule.CheckStock
OrdersModule.PlaceOrder → PricingModule.CalculatePrice
OrdersModule.PlaceOrder → CustomerModule.GetCustomer
OrdersModule.PlaceOrder → TaxModule.CalculateTax
Better - Pricing is part of Ordering
OrdersModule.PlaceOrder → InventoryModule.CheckStock
(Pricing, tax, and customer validation happen within OrdersModule)
If every order operation calls the pricing module, merge pricing into ordering.
Rule 3: Each Module Owns Its Data
No shared databases between modules. Each module has its own tables (or schema):
// Ordering Module
public class OrderingDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<LineItem> LineItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("ordering");
}
}
// Catalog Module
public class CatalogDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("catalog");
}
}
See Modular Monolith Data Isolation for implementation details.
Rule 4: Communicate Through Contracts
Modules don't reference each other's internals. They communicate through integration events or public APIs:
// Shared contract
public sealed record OrderPlacedIntegrationEvent(
Guid EventId,
DateTime OccurredOnUtc,
Guid OrderId,
Guid CustomerId,
decimal TotalAmount) : IIntegrationEvent;
// Ordering Module publishes
await _eventBus.PublishAsync(
new OrderPlacedIntegrationEvent(
Guid.NewGuid(),
DateTime.UtcNow,
order.Id,
order.CustomerId,
order.TotalAmount));
// Shipping Module subscribes
public sealed class OrderPlacedHandler(ShippingDbContext db)
: IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
public async Task HandleAsync(
OrderPlacedIntegrationEvent @event,
CancellationToken cancellationToken = default)
{
var shipment = Shipment.CreateFor(@event.OrderId);
db.Shipments.Add(shipment);
await db.SaveChangesAsync(cancellationToken);
}
}
The IIntegrationEvent and IIntegrationEventHandler<TEvent> abstractions live in the shared kernel, so every module speaks the same contract language.
Common Mistakes
Mistake 1: Entity-Based Modules
Bad - modules based on entities
Modules/
OrderModule/
CustomerModule/
ProductModule/
PaymentModule/
InvoiceModule/
This creates fine-grained modules that constantly communicate. "Place an order" touches 5 modules.
Mistake 2: One Giant Module
Bad - everything in one module
Modules/
ECommerceModule/ ← 200 entities, 150 handlers
If a module has more than 15-20 entities, it's probably doing too much.
Mistake 3: Technical Modules
Bad - modules based on technical concerns
Modules/
ApiModule/
BusinessLogicModule/
DatabaseModule/
MessagingModule/
This is just layers with extra steps. Modules should be business-oriented.
Practical Process
Step 1: List Business Capabilities
- Place an order
- Manage product catalog
- Handle payments
- Ship orders
- Manage customer accounts
- Generate invoices
- Handle refunds
Step 2: Group by Cohesion
Which capabilities change together?
Ordering: Place order, Cancel order, Order status
Catalog: Product management, Categories, Search
Payments: Process payment, Refunds, Payment methods
Shipping: Create shipment, Track delivery, Returns
Identity: User accounts, Authentication, Roles
Step 3: Validate With the "Change Test"
Ask: "If I change feature X, which module is affected?"
- "Change the order discount logic" → Ordering only
- "Add a new product attribute" → Catalog only
- "Change how shipping cost is calculated" → Shipping only
If a change touches multiple modules, your boundaries might be wrong.
Boundaries Are Not Forever
You will get some boundaries wrong. That's expected - you know the least about your domain at the start of the project.
The good news: fixing a boundary inside a monolith is a refactoring, not a migration. Merging two chatty modules means moving files and combining two DbContexts. Splitting an overgrown module is harder, but still a single-codebase exercise. I walk through a real example in refactoring overgrown bounded contexts.
Compare that with microservices, where a wrong boundary is baked into network contracts, separate databases, and independent deployment pipelines. This is the strongest argument for validating boundaries in a modular monolith before extracting anything to a microservice.
Module Structure
src/
Modules/
Ordering/
Ordering.Application/ ← Use cases, handlers
Ordering.Domain/ ← Entities, value objects
Ordering.Infrastructure/ ← EF Core, external services
Ordering.Contracts/ ← Public API, integration events
Catalog/
Catalog.Application/
Catalog.Domain/
Catalog.Infrastructure/
Catalog.Contracts/
The Contracts project is the only one other modules can reference.
Key Takeaways
Drawing module boundaries with bounded contexts:
- Map your business capabilities - not entities, not technical layers
- Each module owns its language - same word, different meaning across modules
- Minimize cross-module communication - chatty modules should merge
- Each module owns its data - separate schemas, no shared tables
- Communicate through contracts - events and public APIs only
- Validate with the change test - a change should affect one module
Get boundaries right, and the rest of the Modular Monolith falls into place.
Thanks for reading, and stay awesome!
Frequently Asked Questions
How do you decide module boundaries in a modular monolith?
Map your business capabilities, group the ones that change together, and align each module with a bounded context. Validate with the change test: a typical business change should affect exactly one module.
What is a bounded context in DDD?
A bounded context is an explicit boundary within which a particular domain model applies. Inside it, every term has one precise meaning. The same real-world concept, like Product, can have different models in different bounded contexts.
How many modules should a modular monolith have?
Most systems land between 4 and 10 modules. Fewer than that and you may be hiding boundaries; many more and you get chatty inter-module communication. Let business capabilities drive the number, not a target count.
Can two modules share the same database table?
No. Shared tables couple modules at the data layer and make future extraction nearly impossible. Each module should own its tables, typically in its own schema, and expose data through contracts or integration events.
What happens if you get module boundaries wrong?
Wrong boundaries show up as chatty communication between modules or changes that always touch several modules. Fixing them means merging or splitting modules, which is far cheaper inside a monolith than across deployed microservices.



