# Defining Module Boundaries With Bounded Contexts

> Draw module boundaries wrong and you fight your own architecture on every feature. Bounded contexts give you a systematic way to draw them: map business capabilities, give each module its own language and data, and validate with the change test. Plus the three boundary mistakes that sink most modular monoliths.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/module-boundaries-bounded-contexts

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**](https://milanjovanovic.tech/blog/bounded-context-ddd-explained) 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**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet).

## Context Maps

Start by mapping the subdomains of your business:

![Context map of four bounded contexts - Ordering, Catalog, Shipping, and Inventory - with Ordering depending on Catalog and Inventory, and Shipping depending on Inventory](https://milanjovanovic.tech/blogs/articles/module-boundaries-bounded-contexts/context-map.png)

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.

```csharp
// 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):

```csharp
// 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**](https://milanjovanovic.tech/blog/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**](https://milanjovanovic.tech/blog/event-driven-communication-modules) or public APIs:

```csharp
// 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**](https://milanjovanovic.tech/blog/shared-kernel-pattern-modular-monolith), 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**](https://milanjovanovic.tech/blog/refactoring-overgrown-bounded-contexts-in-modular-monoliths).

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**](https://milanjovanovic.tech/blog/when-to-extract-module-to-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:

1. **Map your business capabilities** - not entities, not technical layers
2. **Each module owns its language** - same word, different meaning across modules
3. **Minimize cross-module communication** - chatty modules should merge
4. **Each module owns its data** - separate schemas, no shared tables
5. **Communicate through contracts** - events and public APIs only
6. **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.
