A module's public API is the explicit contract other modules are allowed to call, while its domain model, services, and database schema stay internal.
Expose only what another module actually needs, shaped around its use case: GetOrderForShippingAsync rather than a generic GetOrderAsync.
Queries that span modules get their own read model, updated through events.
Three things to know about Depot:
• Up to 3x faster: Depot's Ultra Runners make GitHub Actions jobs 3x faster than GitHub's default runners.
• Ideal for AI workloads, npm install, git clone, and other heavy file operations.
• Get one month free on any plan — Sign up and use code DEPOTFRIENDS24 at checkout — valid through December 20.
Every article about modular monoliths tells you to use public APIs between modules. But they rarely tell you why these APIs exist or how to design them properly.
A modular monolith organizes an application into independent modules that have clear boundaries. The module boundaries are logical and group related business capabilities together.
After building several large-scale modular monoliths, I've learned that public APIs are not just about clean code - they're about controlling chaos. Let me show you what I mean.
The Reality of Module Communication
Here's what nobody tells you about public APIs in modular monoliths: they represent intentional coupling points. Yes, you read that right. Public APIs don't eliminate coupling - they make it explicit and controllable.
When Module A needs something from Module B, you have three options:
- Let Module A read directly from Module B's database
- Let Module A access Module B's internal services
- Create a public API that explicitly defines what Module A can do
The first two options lead to chaos. I've seen entire systems become unmaintainable because every module was freely accessing the data and services of other modules.
The previous options are examples of synchronous communication between modules. But you can also implement asynchonrous module communication using messaging. We have to adjust the technical implementation. However, modules still have a public API in message contracts.
Why We Need Public APIs
Public APIs serve three critical purposes:
- Contract Definition: They explicitly state what other modules can and cannot do
- Dependency Control: They force you to think about module dependencies
- Change Management: They provide a stable interface while allowing internal changes
Here's a practical example. Imagine you have an Orders module and a Shipping module.
This is what you want to avoid:
public class ShippingService
{
private readonly OrdersDbContext _ordersDb; // Direct database access
public async Task ShipOrder(string orderId)
{
// Directly reading from another module's database
var order = await _ordersDb.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == orderId);
// What happens if the Orders module changes its schema?
// What if it moves to a different database?
}
}
This is what you want to achieve instead:
public class ShippingService
{
private readonly IOrdersModule _orders; // Public API access
public async Task ShipOrder(string orderId)
{
// Using the public API
var order = await _orders.GetOrderForShippingAsync(orderId);
// The Orders module can change its internals
// as long as it maintains this contract
}
}
Controlling What Gets Exposed
The hardest part of designing public APIs is deciding what to expose. Here's my rule of thumb:
- Start with nothing public
- Expose only what other modules actually need
- Design the API around use cases, not data
Here's how this looks in practice:
public interface IOrdersModule
{
// Don't expose generic CRUD operations
// Task<Order> GetOrderAsync(string orderId); // Bad
// Instead, expose specific use cases
Task<OrderShippingInfo> GetOrderForShippingAsync(string orderId);
Task<OrderPaymentInfo> GetOrderForPaymentAsync(string orderId);
Task<OrderSummary> GetOrderForCustomerAsync(string orderId);
}
Protecting Your Module's Data
Public APIs aren't enough. You also need to protect your module's data. Here's what I've found works:
- Separate Schemas: Each module gets its own database schema.
CREATE SCHEMA Orders;
CREATE SCHEMA Shipping;
-- Orders module can only access its schema
CREATE USER OrdersUser WITH DEFAULT_SCHEMA = Orders;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::Orders TO OrdersUser;
-- Shipping module can only access its schema
CREATE USER ShippingUser WITH DEFAULT_SCHEMA = Shipping;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::Shipping TO ShippingUser;
We can also lock down the user's access to a given schema to only allow reading and writing data.
- Different Connection Strings: Each module gets its own database user with a respective connection string.
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("OrdersConnection")));
builder.Services.AddDbContext<ShippingDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("ShippingConnection")));
If you want to learn more about this, check out this article about using multiple EF Core DbContexts.
- Read Models: Create specific read models for other modules.
internal class Order
{
// Internal domain model with full complexity
}
public class OrderShippingInfo
{
// Public DTO with only what shipping needs
public string OrderId { get; init; }
public Address ShippingAddress { get; init; }
public List<ShippingItem> Items { get; init; }
}
Dealing with Cross-Cutting Concerns
Some features naturally span multiple modules. For example, when a customer views their order history, you might need data from the Orders, Shipping, and Payments modules.
Don't try to force this through module APIs. Instead:
- Create a separate query model
- Use event-driven patterns to keep it updated
- Own it in a dedicated module or one of the existing modules
public class OrderHistoryModule
{
public async Task<CustomerOrderHistory> GetOrderHistoryAsync(string customerId)
{
// Read from a dedicated read model that's kept
// updated through events from other modules
return await _orderHistoryRepository.GetCustomerHistoryAsync(customerId);
}
}
Summary
Public APIs in modular monoliths are not about preventing coupling - they're about controlling it. Every public API is a contract that says: "Yes, these modules are coupled, and this is exactly how they depend on each other."
The goal isn't to eliminate dependencies between modules. The goal is to make them explicit, controlled, and maintainable.
Get this right, and your modular monolith will be easier to maintain, test, and evolve. Get it wrong, and you'll end up with a distributed big ball of mud.
Want to master building modular monoliths with clean APIs and event-driven patterns? Check out my Modular Monolith Architecture course, where I'll show you how to build maintainable systems using practical examples from real projects.
That's all for today. Stay awesome, and I'll see you next week.
Frequently Asked Questions
What is a public API in a modular monolith?
A public API is the explicit contract a module exposes to other modules. It doesn't eliminate coupling; it makes coupling intentional and controllable by stating exactly what other modules can do, controlling dependencies, and allowing internal changes behind a stable interface.
Why shouldn't modules access each other's databases directly?
Direct database access couples a module to another module's schema, so any schema change or database move breaks it. The same applies to calling another module's internal services. Systems where every module freely reads others' data become unmaintainable.
What should a module's public API expose?
Start with nothing public and expose only what other modules actually need. Design the API around use cases rather than data: instead of a generic GetOrder operation, expose purpose-built methods like GetOrderForShipping that return a DTO with only the fields the consumer needs.
How do you protect module data in a modular monolith?
Give each module its own database schema and its own database user with a separate connection string, so a module can only touch its own tables. Keep domain models internal and expose public read models (DTOs) that contain only what other modules need.
How do you handle queries that span multiple modules?
Don't force cross-module features through module APIs. Create a separate query model, keep it updated through events from the other modules, and own it in a dedicated module. A customer order history view can then read from that one model.



