Internal vs. Public APIs in Modular Monoliths
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.
Whenever you're ready, there are 4 ways I can help you:
(COMING SOON) REST APIs in ASP.NET Core: You will learn how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API. Join the waitlist!
Pragmatic Clean Architecture: Join 3,600+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
Modular Monolith Architecture: Join 1,600+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
Patreon Community: Join a community of 1,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.