Finding Module Boundaries: Business Capabilities, Bounded Contexts and Data Ownership
Module boundaries are drawn by business capability and data ownership, not by technical layer: each bounded context becomes a module and each table gets exactly one writer. Using an EF Core interceptor in Bookstore we measure three modules writing to the Books table and two to Orders, show why the Book inside an Order must be a separate snapshot model, and list the symptoms of a wrong boundary.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's current database has a Books table: title, list price and a StockOnHand column that was added "to keep things simple". Orders live in the Orders and OrderItems tables, and the order status sits in Orders.Status. The code can reach every table through one BookstoreDbContext.
An ordinary week goes by: the Catalog team lowers the price of Dune, a customer orders two copies of Dune, ten copies arrive at the warehouse, the order is shipped and the customer gets a confirmation email. During that week, how many different modules write to the Books table? How many to Orders?
Second question: last month two copies of Dune were sold at 12.50 EUR (Euro). Today Catalog raised the price to 15.00 EUR. When the customer opens the "My orders" page, what total should they see for that order, and what does our code show? The answer depends on which module the "book" inside the order belongs to.
Draw the Boundary by Capability, Not by Technical Layer
When drawing a module boundary, the first reflex is often technical: "a Data module, an API (Application Programming Interface) module, a Reporting module." The Introduction to Architecture series gave that a name: technical partitioning. A modular monolith takes domain partitioning seriously instead; the top-level split follows business areas and the technical roles move down inside each module.
The most useful way to describe a business area is a business capability: what the business does, independent of how it does it. Bookstore keeps a catalogue of books, takes orders, manages stock, collects payments, ships parcels and notifies customers. Those verbs barely change over the years; what changes is how they are done (which payment provider, which carrier, which screen). If you draw the boundary around the stable thing, the details that change often stay inside it.
.NET itself has a clean example of this: in ASP.NET Core, authentication and authorization are two separate capabilities and two separate families of assemblies. Let's look at their references to see who knows whom:
using System.Reflection;
// Authentication and authorization are separate capabilities: who knows whom?
string[] assemblies =
[
"Microsoft.AspNetCore.Authentication",
"Microsoft.AspNetCore.Authentication.Abstractions",
"Microsoft.AspNetCore.Authorization",
"Microsoft.AspNetCore.Authorization.Policy"
];
foreach (string name in assemblies)
{
IEnumerable<string?> refs = Assembly.Load(name).GetReferencedAssemblies()
.Select(r => r.Name).Where(n => n!.Contains("Auth")).Order();
Console.WriteLine($"{name,-50} -> [{string.Join(", ", refs)}]");
}
Microsoft.AspNetCore.Authentication -> [Microsoft.AspNetCore.Authentication.Abstractions, Microsoft.AspNetCore.Authentication.Core]
Microsoft.AspNetCore.Authentication.Abstractions -> []
Microsoft.AspNetCore.Authorization -> []
Microsoft.AspNetCore.Authorization.Policy -> [Microsoft.AspNetCore.Authentication.Abstractions, Microsoft.AspNetCore.Authorization]
The core of authorization does not reference authentication at all; it works only with a ClaimsPrincipal and has no idea whether the user arrived with a cookie or a token. Even Authorization.Policy, which joins the two, depends only on the Abstractions assembly, not on authentication's implementation. A boundary per capability with a small contract between them: exactly the structure this series will build for Bookstore.
How It Relates to Bounded Contexts
In the Domain-Driven Design (DDD) series post "Bounded Context: Same Word, Different Meanings, and Model Boundaries" we defined a bounded context as the boundary within which a model applies, and that series covered discovery techniques such as EventStorming and context maps. We won't repeat them here; the question is how discovered contexts turn into modules.
The safest starting rule: one bounded context, one module. Bookstore's six modules are the same as the six contexts in the DDD series. A one-to-one mapping is not mandatory, though. In the DDD series we saw two core subdomains (the order lifecycle and promotion pricing) solved together inside the Ordering context. Today both live in the same module; if Pricing grows its own rules and its own team one day, it becomes a separate module. The opposite is wrong: splitting one bounded context across two modules means the same model lives in two places, and every change forces you to change both modules together.
The "Book Inside the Order" Question
The second opening question shows why boundaries start with the model. We wrote the two options side by side. In the first, the order holds Catalog's Book directly. In the second, the order line copies the information it needs at order time:
using System.Globalization;
using Bookstore.Catalog;
using Bookstore.Ordering;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book dune = new(Guid.CreateVersion7(), "978-0441013593", "Dune", 12.50m);
// Option 1: the order holds Catalog's Book
SharedModelOrder shared = new();
shared.Add(dune, 2);
// Option 2: the order line copies what it needs at order time
Order order = new();
order.Add(new OrderItem(dune.Id, dune.Title, dune.ListPrice, 2));
Console.WriteLine($"Before price change: shared {shared.Total}, snapshot {order.Total}");
dune.ChangePrice(15.00m); // the year-end promotion ended, list price went up
Console.WriteLine($"After price change : shared {shared.Total}, snapshot {order.Total}");
namespace Bookstore.Catalog
{
// Catalog's book: searched, its price changes
public sealed class Book(Guid id, string isbn, string title, decimal listPrice)
{
public Guid Id { get; } = id;
public string Isbn { get; } = isbn;
public string Title { get; } = title;
public decimal ListPrice { get; private set; } = listPrice;
public void ChangePrice(decimal newPrice) => ListPrice = newPrice;
}
}
namespace Bookstore.Ordering
{
using Bookstore.Catalog;
public sealed class SharedModelOrder
{
private readonly List<(Book Book, int Quantity)> _lines = [];
public void Add(Book book, int quantity) => _lines.Add((book, quantity));
public decimal Total => _lines.Sum(l => l.Book.ListPrice * l.Quantity);
}
// Ordering's "book": title and price at the moment of sale
public sealed record OrderItem(Guid BookId, string Title, decimal UnitPrice, int Quantity);
public sealed class Order
{
private readonly List<OrderItem> _items = [];
public void Add(OrderItem item) => _items.Add(item);
public decimal Total => _items.Sum(i => i.UnitPrice * i.Quantity);
}
}
Before price change: shared 25.00, snapshot 25.00
After price change : shared 30.00, snapshot 25.00
With the shared model, last month's order total quietly went from 25.00 to 30.00 because of Catalog's price change. If accounting, refunds and invoicing look at this number, the bug is no longer a reporting problem but a money problem. With the snapshot model the order keeps the truth as it was at the moment of sale.
This is not an exception to a "copying is bad" rule; it is two different concepts being modelled correctly. The Book in Catalog is "the title currently on sale": search, description, current list price. The OrderItem in Ordering is "what was sold in this order": BookId, the title at that moment, the UnitPrice after promotions and the quantity. The StockItem in Inventory counts the physical copies of the same book in the warehouse, and Shipping's parcel cares about its weight. Four modules, four different models for the same ISBN (International Standard Book Number), and the only thing they share is identity: BookId. When you think one module needs another module's entity, more often than not what you actually need is that entity's id and a copy of a few of its fields as they were at that moment.
Data Ownership: One Writer per Table
The clearest marker of a module's boundary is its data. The practical rule: exactly one module writes to each table. Many may read (in later posts we will restrict that too, with contracts and events), but one module is responsible for a record being correct.
How badly does today's Bookstore break this rule? Instead of guessing, we measured. We added an EF (Entity Framework) Core interceptor to the legacy BookstoreDbContext; before every SaveChanges it records which module's code is writing to which table. To know the module, every service calls ModuleScope.Enter(...) at the start of its method:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Bookstore.Legacy;
// Which module's code is running right now?
public static class ModuleScope
{
private static readonly AsyncLocal<string?> Current = new();
public static string Name => Current.Value ?? "?";
public static IDisposable Enter(string module)
{
string? previous = Current.Value;
Current.Value = module;
return new Exit(() => Current.Value = previous);
}
private sealed class Exit(Action undo) : IDisposable { public void Dispose() => undo(); }
}
// Right before SaveChanges, records who writes to which table
public sealed class WriterLog : SaveChangesInterceptor
{
public HashSet<(string Table, string Module)> Writes { get; } = [];
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
foreach (var entry in eventData.Context!.ChangeTracker.Entries())
if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
Writes.Add((entry.Metadata.GetTableName()!, ModuleScope.Name));
return result;
}
}
Today's checkout also decrements stock itself:
namespace Bookstore.Ordering
{
public sealed class CheckoutService(BookstoreDbContext db)
{
public int PlaceOrder(int customerId, int bookId, int quantity)
{
using IDisposable _ = ModuleScope.Enter("Ordering");
Book book = db.Books.Find(bookId)!;
book.StockOnHand -= quantity; // "decrement stock" inside checkout
Order order = new() { CustomerId = customerId, Items = [new() { BookId = bookId, Quantity = quantity, UnitPrice = book.ListPrice }] };
db.Orders.Add(order);
db.SaveChanges();
return order.Id;
}
}
}
We played the opening week and grouped the tables by their writers:
new PriceService(db).ChangePrice(1, 11.00m);
int orderId = new CheckoutService(db).PlaceOrder(customerId: 1, bookId: 1, quantity: 2);
new GoodsReceiptService(db).Receive(bookId: 1, quantity: 10);
new DispatchService(db).Ship(orderId, "TRK-1001");
new OrderEmailService(db).SendConfirmation(customerId: 1);
foreach (var table in log.Writes.GroupBy(w => w.Table).OrderBy(g => g.Key))
{
string[] writers = [.. table.Select(w => w.Module).Order()];
string flag = writers.Length > 1 ? " <- more than one writer" : "";
Console.WriteLine($"{table.Key,-11} {string.Join(", ", writers),-30}{flag}".TrimEnd());
}
Books Catalog, Inventory, Ordering <- more than one writer
Customers Notification
OrderItems Ordering
Orders Ordering, Shipping <- more than one writer
The answer to the first question: three modules write to the Books table. The price belongs to Catalog, decrementing stock to Ordering and goods receipt to Inventory, but they all meet on the same row. That is the strongest sign that the boundary runs in the wrong place: stock is not a property of a book but of a record in the warehouse. The right move is to move StockOnHand into Inventory's own StockItem record and have Ordering send a reservation request to Inventory instead of decrementing stock.
In the Orders table, Shipping writes the order status and the tracking number itself. The shipping process is Shipping's data (Shipment, TrackingNumber); Ordering should update its own status by reacting to the OrderShipped event that Shipping publishes. The Customers table is subtler: it has a single writer, but the wrong one. The customer record belongs to Ordering, while "when did the last email go out" is part of Notification's own EmailMessage records. The single-writer rule does not guarantee that the writer is the right module; the matrix just makes you ask the question.
Running this interceptor for a week with real scenarios in your own application's test environment is a cheap way to move boundary debates from opinions to data.
Cohesion and Coupling Signals
No single measure tells you whether a boundary is right; you look for several signals pointing the same way:
- What changes together stays together: if the same business request keeps touching the same two modules, the boundary is probably wrong. In the Introduction to Architecture series post "Conway's Law: How Team Structure Shapes Architecture" we wrote a tool that measures this from commit history; you can run the same tool with module ownership instead of teams.
- Language boundary: if the same word means different things in two places ("book" is a title in Catalog and a physical copy in Inventory), there is a boundary between them.
- Invariant boundary: rules that must be consistent at the same moment, in a single transaction, belong in the same module. "At most 10 copies of the same book per order" stays inside Ordering; consistency between stock and orders can tolerate a delay of a few seconds.
- Data boundary: does each table have exactly one writer?
- Team boundary: can each of the four teams planned for 12–18 months from now be the sole owner of a module?
The "Which Change Touches Which Modules?" Test
You can turn the most practical of these signals into a paper exercise: take the real business requests from the last few months and write down which modules each would touch under your proposed boundaries. Six requests from Bookstore's backlog:
| Business request | Module whose code changes | Only listens to an event |
|---|---|---|
| New payment method | Payments | — |
| Publication year filter on the search page | Catalog | — |
| Back-to-school promotion (10% off 3+ books) | Ordering | — |
| Partial order cancellation | Ordering | Inventory, Payments |
| Shipment tracking page | Shipping | — |
| SMS (Short Message Service) to the customer when the parcel ships | Notification | — |
Most requests change the code of a single module. Partial cancellation starts in Ordering; Inventory and Payments only react to an event such as OrderCancelled, and the change on their side amounts to adding a handler. That was exactly one of Bookstore's architecture goals: a new payment method must be addable with changes only in the Payments module, in at most 3 developer-days. If a request in the table touched the code of four modules at once, either the boundary is wrong or the request was really several separate requests.

Symptoms of a Wrong Boundary
You often realise a boundary is wrong only a few months after writing the code. The symptoms: almost every feature touches three modules at once; two modules call each other constantly, dozens of times within the same request (chatty communication); the places that need a single transaction across modules keep multiplying; there is a circular dependency between two modules; and the most familiar one, a module called "Common" or "Shared" grows a little more every sprint.
That last one deserves extra attention. Money goes into "Common" first, which is reasonable. Then Customer goes in because two modules use it, then OrderStatus, then a BookDto. After a while every module is coupled to every other through "Common", and every change in "Common" breaks everyone. In post 03 we will discuss, with a concrete list, what may and may not go into a Shared Kernel.
Common Pitfalls
One module per entity
CustomerModule, BookModule, OrderModule. Entities are not capabilities; a capability holds several entities, and one entity has different models in different capabilities. Splitting by entity reproduces the "book inside the order" question everywhere.
Technical modules
Modules called Reporting, Integration or Persistence are technical partitioning in disguise. Reporting can be a real capability (a separate team, a separate data model), but if it is built as "the place that reads and joins every module's tables", it single-handedly breaches every boundary. In post 06 we will see how to do reporting without breaking isolation.
Drawing the boundary today and never changing it
The warning from Fowler's MonolithFirst applies here too: getting the boundaries right up front is hard. The advantage of a modular monolith is that you can move a wrong boundary with a single refactoring. If you don't use that advantage, that is, if you freeze the boundaries because "it was decided once", you keep the most expensive part and give up the cheapest.
Treating Notification as a helper that reads everyone's tables
Generic capabilities like Notification are the ones that need other modules' data the most: the customer's email, the order contents, the tracking number. Instead of reading those from tables, ask for them to be carried inside the event or queried through a contract. Notification's own data should be the record of the messages it has sent.
A Small Experiment
★☆☆ Take the last 20 business requests of your own application and fill in the table above with your proposed modules. How many requests touch the code of a single module?
★★☆ Add the WriterLog interceptor to your application's integration tests. Try finding the module from the caller's namespace instead of AsyncLocal (hint: System.Diagnostics.StackTrace). Which table turned out to have more than one writer?
★★★ Write a refactoring plan that moves Books.StockOnHand to Inventory: a new StockItem table, a data migration script, Ordering requesting a reservation from Inventory instead of decrementing stock. How do you keep the two columns consistent during the transition?
In this post we talked about where to draw the boundaries. In the next post we pour them into a .NET solution: Contracts and Core projects per module, internal, each module's own registration extensions and a Host that is nothing but the composition root.