DEV Community

Cover image for Internal vs. Public APIs in Modular Monoliths
Milan Jovanović
Milan Jovanović

Posted on • Originally published at milanjovanovic.tech on

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:

  1. Let Module A read directly from Module B's database
  2. Let Module A access Module B's internal services
  3. Create a public API that explicitly defines what Module A can do

Image description

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:

  1. Contract Definition : They explicitly state what other modules can and cannot do
  2. Dependency Control : They force you to think about module dependencies
  3. 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?
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

Controlling What Gets Exposed

The hardest part of designing public APIs is deciding what to expose. Here's my rule of thumb:

  1. Start with nothing public
  2. Expose only what other modules actually need
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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;
Enter fullscreen mode Exit fullscreen mode

We can also lock down the user's access to a given schema to only allow reading and writing data.

  1. 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")));
Enter fullscreen mode Exit fullscreen mode

If you want to learn more about this, check out this article about using multiple EF Core DbContexts.

  1. 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; }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a separate query model
  2. Use event-driven patterns to keep it updated
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


P.S. Whenever you're ready, there are 3 ways I can help you:

  1. 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.

  2. 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.

  3. Patreon Community: Join a community of 1,050+ 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.

Top comments (0)