Synchronous Communication Between Modules: Public Contracts, In-Process APIs and Preventing Entity Leaks
Modules should call each other through an interface in the Contracts project, with methods that return records and are shaped by the consumer's scenario; injecting another module's DbContext turns that module's internal model into a public contract. We measure per-line versus per-cart calls for a 20-book cart, and show the runtime circular dependency the Contracts split misses and how a refactoring in Catalog breaks Ordering.
Version note: The code in this post was built and run with C# 14, .NET 10 and Entity Framework Core (EF Core) 10.
Guess First
The back-to-school promotion has started and a teacher has put 20 different books in the cart. During checkout, Ordering has to learn two things for every line: the book's current price in Catalog and the available stock in Inventory. The contracts from the previous post have a "single book" method in both modules, and the first idea is to call them inside a foreach.
How many contract calls does this checkout make, and how many SQL (Structured Query Language) commands reach Catalog's database? What happens to those numbers if the same job is done with one call per cart? How big do you think the time difference is, and how would it change if Catalog moved into a separate service one day?
A second question: Catalog wants a rule too, "a book with open orders must not change price". If it calls Ordering's contract for that, and Ordering already calls Catalog, will the compiler say anything?
The Interface a Module Exposes
In a modular monolith, synchronous communication between modules is an ordinary method call: Ordering takes an interface from Catalog's Contracts project out of the DI (Dependency Injection) container and calls it. No network, no serialization, the same process and most of the time the same thread. So the subject is not technical but design: what does the shape of that interface allow, and what does it prevent?
Catalog's contract as it stands in this post:
using Bookstore.SharedKernel;
namespace Bookstore.Catalog.Contracts;
public readonly record struct BookId(Guid Value);
public sealed record BookSummary(BookId Id, string Isbn, string Title, Money ListPrice);
public interface ICatalogModuleApi
{
Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default);
// The cart is asked about in one go: N books, one call
Task<IReadOnlyDictionary<BookId, BookSummary>> GetBooksAsync(IReadOnlyCollection<BookId> ids, CancellationToken ct = default);
}
A good module contract has a few properties. It is named after the use case, not after tables: GetBooksAsync answers Ordering's question "the books in this cart". The return type is a record or a collection of records; an entity, an IQueryable or an object tied to EF Core's tracking never leaves. Every method takes a CancellationToken, because if the caller's HTTP (Hypertext Transfer Protocol) request is cancelled, Catalog's query should stop too. And it states expected failures through the type itself rather than exceptions: a book that isn't found is null, a key missing from the dictionary.
A well-known example of this shape in .NET is authorization. When you call IAuthorizationService.AuthorizeAsync, none of the policies, requirements or handlers leave; the caller only gets an AuthorizationResult: did it succeed, and if not, which requirements failed. How a policy is evaluated inside can change without a single line of the calling code changing.
Catalog's implementation maps the entity to the contract's record:
using Bookstore.Catalog.Contracts;
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Catalog.Core;
internal sealed class CatalogModuleApi(CatalogDbContext db) : ICatalogModuleApi
{
public async Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
await db.Books.AsNoTracking()
.Where(b => b.Id == id.Value)
.Select(b => new BookSummary(new BookId(b.Id), b.Isbn, b.Title, new Money(b.ListPrice, b.Currency)))
.SingleOrDefaultAsync(ct);
public async Task<IReadOnlyDictionary<BookId, BookSummary>> GetBooksAsync(IReadOnlyCollection<BookId> ids, CancellationToken ct = default)
{
Guid[] keys = [.. ids.Select(i => i.Value)];
// The entity never leaves; it is mapped to the contract's record
List<BookSummary> books = await db.Books.AsNoTracking()
.Where(b => keys.Contains(b.Id))
.Select(b => new BookSummary(new BookId(b.Id), b.Isbn, b.Title, new Money(b.ListPrice, b.Currency)))
.ToListAsync(ct);
return books.ToDictionary(b => b.Id);
}
}
Projecting straight into BookSummary with Select does two jobs: EF Core reads only the columns it needs, and the entity never leaves Catalog.
Ordering Asks Through the Contract
Ordering's checkout handler goes to each module once per cart:
using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Core;
internal sealed record CartLine(Guid BookId, int Quantity);
internal sealed record PlaceOrderRequest(IReadOnlyList<CartLine> Lines);
internal sealed record OrderConfirmation(Guid OrderId, int Lines, decimal Total, string Currency);
internal sealed class PlaceOrderHandler(ICatalogModuleApi catalog, IInventoryModuleApi inventory, OrderStore orders)
{
public async Task<Result<OrderConfirmation>> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
BookId[] ids = [.. request.Lines.Select(l => new BookId(l.BookId)).Distinct()];
// One call per module, however big the cart is
IReadOnlyDictionary<BookId, BookSummary> books = await catalog.GetBooksAsync(ids, ct);
IReadOnlyDictionary<BookId, int> stock = await inventory.GetAvailabilityAsync(ids, ct);
List<OrderItem> items = [];
foreach (CartLine line in request.Lines)
{
BookId id = new(line.BookId);
if (!books.TryGetValue(id, out BookSummary? book))
return new Error("order.unknown-book", $"Book {line.BookId} does not exist");
if (stock[id] < line.Quantity)
return new Error("order.out-of-stock", $"Only {stock[id]} of '{book.Title}' available");
items.Add(new OrderItem(line.BookId, book.Title, book.ListPrice, line.Quantity));
}
Order order = new(Guid.CreateVersion7(), items);
orders.Add(order);
Money total = order.Total;
return new OrderConfirmation(order.Id, items.Count, total.Amount, total.Currency);
}
}
$ curl -s -X POST localhost:5107/api/ordering/orders -H "Content-Type: application/json" \
-d '{"lines":[{"bookId":"0199b1a0-0000-7000-8000-000000000001","quantity":2},{"bookId":"0199b1a0-0000-7000-8000-000000000002","quantity":1}]}'
{"orderId":"01a1b87d-eaa9-7ca9-b7bc-44a5115843ed","lines":2,"total":30.0,"currency":"EUR"}
The price written to the order line is the snapshot rule from the second post: OrderItem copies Catalog's ListPrice at that moment and is not affected by later changes in Catalog.
The Cost of Chatty Communication
We measured the first opening question. A small program that boots the modules like a composition root asked about the same 20-book cart in two ways and counted executed SQL commands from the diagnostic events EF Core publishes:
using System.Diagnostics;
using System.Globalization;
using Bookstore.Catalog.Contracts;
using Bookstore.Catalog.Core;
using Bookstore.Inventory.Contracts;
using Bookstore.Inventory.Core;
using Microsoft.Extensions.DependencyInjection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// Count executed SQL commands from the diagnostic events EF Core publishes
int sqlCommands = 0;
using IDisposable subscription = DiagnosticListener.AllListeners.Subscribe(new Observer<DiagnosticListener>(listener =>
{
if (listener.Name == "Microsoft.EntityFrameworkCore")
listener.Subscribe(new Observer<KeyValuePair<string, object?>>(e =>
{
if (e.Key == "Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted") Interlocked.Increment(ref sqlCommands);
}));
}));
ServiceCollection services = new();
services.AddCatalogModule("Data Source=probe.db").AddInventoryModule();
using ServiceProvider provider = services.BuildServiceProvider();
await provider.SeedCatalogAsync(books: 20);
using IServiceScope scope = provider.CreateScope();
ICatalogModuleApi catalog = scope.ServiceProvider.GetRequiredService<ICatalogModuleApi>();
IInventoryModuleApi inventory = scope.ServiceProvider.GetRequiredService<IInventoryModuleApi>();
BookId[] cart = [.. Enumerable.Range(1, 20).Select(n => new BookId(DemoIds.Book(n)))];
await Measure("warm-up", async () => { await catalog.GetBooksAsync(cart); await catalog.GetBookAsync(cart[0]); return 0; });
await Measure("chatty (per line)", async () =>
{
int calls = 0;
foreach (BookId id in cart)
{
await catalog.GetBookAsync(id); calls++;
await inventory.GetAvailableAsync(id); calls++;
}
return calls;
});
await Measure("batch (per cart)", async () =>
{
await catalog.GetBooksAsync(cart);
await inventory.GetAvailabilityAsync(cart);
return 2;
});
async Task Measure(string name, Func<Task<int>> run)
{
int before = sqlCommands;
long start = Stopwatch.GetTimestamp();
int calls = await run();
TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
if (name != "warm-up")
Console.WriteLine($"{name,-18} contract calls: {calls,2} SQL commands: {sqlCommands - before,2} elapsed: {elapsed.TotalMilliseconds,6:F2} ms");
}
sealed class Observer<T>(Action<T> onNext) : IObserver<T>
{
public void OnNext(T value) => onNext(value);
public void OnCompleted() { }
public void OnError(Exception error) { }
}
chatty (per line) contract calls: 40 SQL commands: 20 elapsed: 2.02 ms
batch (per cart) contract calls: 2 SQL commands: 1 elapsed: 0.94 ms
Calling per line makes 40 contract calls and 20 SQL commands; Inventory reads from memory in this example, and with its own database there would be 20 more commands. Calling per cart makes 2 contract calls and 1 SQL command. The time difference on this machine is about twofold, because SQLite runs in the same process and each command takes microseconds. On a real SQL Server every command is a network round trip; a 20-book cart turns into 20 round trips.
The real difference shows up when you recall the measurement from the first post. There we saw that an HTTP call on the same machine is more than a thousand times slower than an in-process call. If Catalog becomes a separate service one day, the per-line code makes 40 network calls; the per-cart code makes 2. A chatty contract is a debt that goes unnoticed today and blows up the day you extract the module into a service. Asking "would I choose this shape if the call went over the network?" while designing a contract is the cheapest way to prepare for that day without adding HTTP today.
Call Direction and Circular Dependencies
The second question. Catalog uses a new Ordering contract so that a book with open orders keeps its price; to produce its answer, that Ordering contract calls Catalog. There is no cycle at the project level: Catalog.Core → Ordering.Contracts, Ordering.Core → Catalog.Contracts. The build succeeds. The application starts in Production and returns a 500 on the first book request:
System.InvalidOperationException: A circular dependency was detected for the service of type 'Bookstore.Catalog.Contracts.ICatalogModuleApi'.
Bookstore.Catalog.Contracts.ICatalogModuleApi(Bookstore.Catalog.Core.CatalogModuleApi) -> Bookstore.Catalog.Core.PriceChangeGuard -> Bookstore.Ordering.Contracts.IOrderingModuleApi(Bookstore.Ordering.Core.OrderingModuleApi) -> Bookstore.Catalog.Contracts.ICatalogModuleApi
In the Development environment ASP.NET Core turns on the DI container's ValidateOnBuild check, so the same error arrives while the application starts:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the
Separating Contracts from Core prevents cycles between projects but not cycles between modules; the cycle just moves from compile time to run time. The rule should be: synchronous calls between modules flow in one direction. In Bookstore's context map Catalog is upstream and Ordering downstream. Ordering asks Catalog; Catalog does not ask Ordering.
Catalog's rule is still legitimate, but its solution is not a synchronous call. Either Catalog keeps "this book has open orders" in its own table, fed by the OrderPlaced and OrderShipped events Ordering publishes (the subject of the next post), or the rule really belongs to Ordering rather than Catalog: even if the price changes, an open order already holds its own snapshot. The second answer is usually the right one; a circular dependency generally tells you a responsibility sits in the wrong module.
Pitfall: Injecting Another Module's DbContext
The contract's biggest rival is the thought "the table is right there". To see it, we made Catalog's CatalogDbContext and Book public, added a Catalog.Core reference to Ordering and wrote a class that prices the cart straight from Catalog's table instead of the contract:
using Bookstore.Catalog.Core;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Core;
// Catalog's DbContext injected directly: "no need for a contract, the table is right there"
internal sealed class CartPricing(CatalogDbContext catalogDb)
{
public async Task<decimal> TotalAsync(IReadOnlyCollection<CartLine> lines, CancellationToken ct)
{
Guid[] ids = [.. lines.Select(l => l.BookId)];
Dictionary<Guid, decimal> prices = await catalogDb.Books
.Where(b => ids.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, b => b.ListPrice, ct);
return lines.Sum(l => prices[l.BookId] * l.Quantity);
}
}
The code compiles and runs. A few weeks later the Catalog team makes a reasonable refactoring of its own: it combines the ListPrice and Currency columns into a single Money value object using EF Core's complex type support.
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Catalog.Core;
public sealed class Book
{
public Guid Id { get; init; }
public required string Isbn { get; init; }
public required string Title { get; set; }
public Money Price { get; set; } // a single value object instead of ListPrice + Currency
}
public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Book>().ComplexProperty(b => b.Price);
}
The only change in Catalog's contract implementation is one expression in the projection: b.Price instead of new Money(b.ListPrice, b.Currency). Catalog compiles and requests through the contract get the same answer. But Ordering breaks:
src/Modules/Ordering/Bookstore.Ordering.Core/CartPricing.cs(14,50): error CS1061: 'Book' does not contain a definition for 'ListPrice' and no accessible extension method 'ListPrice' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
Catching this at compile time is the lucky case. Worse is Ordering going to Catalog's table with raw SQL or through a reporting view: then the break shows up in production as a runtime exception, or perhaps as a wrong total. Injecting another module's DbContext turns that module's internal model into your public contract; Catalog can no longer change its own table without asking Ordering. EF Core also won't let you use two separate context instances in one query, so this road usually ends in "one giant DbContext". A DbContext and schema per module are in post 06.
Pitfall: One Method That Returns Everything
The opposite of a chatty contract is a mistake too. Picture a single GetBookDetailsAsync(BookId) designed "to serve every need": title, price, author biography, category tree, cover image links, latest reviews. Ordering wants only title and price at checkout, yet every call runs a query in which Catalog joins six tables. More importantly, every field added to that record is a contract change and potentially affects every module that uses it.
A similar disguise is a generic entry point: Task<object> ExecuteAsync(string operation, object payload). The compiler can no longer check anything; the contract becomes a hidden API (Application Programming Interface) made of strings. The ExecuteCommandAsync/ExecuteQueryAsync entry points in Grzybek's sample are different: there every command and query has its own type and the compiler checks them.
The measure is simple: every method on a contract should have a consumer, and that consumer a scenario. GetBooksAsync for Ordering's checkout; for Shipping printing labels, perhaps a separate method that also returns the weight. Keeping a contract small, shaped by its consumer's needs, is the Interface Segregation Principle at module level.
When Sync, When an Event?
Not every interaction between modules has to be a contract call. The most practical way to tell is whether the caller needs the answer now:
| Situation | Choice | Bookstore example |
|---|---|---|
| The caller needs the answer now to continue | Synchronous contract call | Price and available stock at checkout |
| Something happened, other modules should do their own work | Integration event | Reserving stock and creating a shipment after OrderPlaced |
| A delay in the reaction is acceptable | Integration event | Order confirmation email |
| The answer can come from a copy of another module's data | Local read model kept up to date by events | "Has open orders" in Catalog |
| Upstream wants to ask downstream something | Rethink: is the responsibility in the right place? | Catalog asking Ordering |
A synchronous call has another cost: the called module's failure at that moment becomes the caller's failure. If Inventory's contract throws, checkout fails too. As long as we are in the same process this is an acceptable dependency; but one module waiting synchronously for work another module could do "later" ties the availability of the two modules together for no reason.
Common Pitfalls
Returning entities or IQueryable from a contract
A contract that returns IQueryable<Book> carries Catalog's table into Ordering's query. As Ordering adds Where and Join, Catalog's indexes, columns and even database technology become Ordering's problem. A contract returns materialised records.
Growing the contract into a copy of a class in Core
If nobody asks "who uses this?" each time a property is added to the contract, the contract eventually becomes the entity itself. For a new field, ask about the consumer's scenario first.
Putting cross-module calls inside a transaction
If Ordering opens its own transaction and calls Inventory's contract from within it, it appears to work today because we share a database. But it ties the data of two modules to a single transaction, and as we will see in post 06, that is a sign the boundary may be wrong.
Changing contracts without versioning
For modules compiled in the same solution, the compiler catches a breaking change and it gets fixed in the same commit. That is an advantage, but the comfort of "the compiler will tell us anyway" encourages changing contracts often. Keep a contract as stable as if it were going to cross the network one day.
A Small Experiment
★☆☆ Run ChattyProbe with carts of 100 and 500 books. How do the SQL command count and the duration scale?
★★☆ Add a separate contract method to Catalog that returns what Shipping needs for a shipping label (title and weight in grams). Write in one sentence why you chose a separate record instead of adding weight to BookSummary.
★★★ Fix the circular dependency example: design how Catalog could learn "this book has open orders" without calling Ordering. Which events does it need to listen to, and which column should Catalog's table keep? We will write the code with the in-process bus in the next post.
In this post we discussed how modules ask each other things. In the next post we turn to how modules tell each other things: integration events, a small library-free in-process dispatcher, events lost when the process crashes and a minimal outbox that fixes it.