Internal vs Public APIs in Modular Monoliths (.NET Guide)
Learn the difference between internal and public APIs in a .NET modular monolith, why module boundaries matter, and how to expose only safe contracts to other modules.
The hotel front desk
Imagine you visit a big hotel in your city.
You walk in and go straight to the front desk. You ask for a room, you ask for the Wi-Fi password, you ask them to wake you at 6 am. The person at the desk helps you with all of it. That desk is the one door between you and the whole hotel.
Now think about everything you did not do. You did not walk into the kitchen and start cooking. You did not enter the laundry room and grab someone's clothes. You did not go into the manager's office and open the safe. Those rooms exist, they are busy, and they are important. But they are not for guests. They are private.
The hotel works smoothly because of this simple rule: guests use the front desk; staff areas stay private. The kitchen can change its menu, the laundry can buy a new machine, and you, the guest, never even notice. Your only promise is the front desk, and the front desk keeps working the same way.
A modular monolith works the same way. It is one application, like one hotel building. Inside, it has modules: Orders, Billing, Shipping, and so on. Each module has a small front desk that other modules are allowed to use. That front desk is its public API. Everything behind the desk, all the real work, is the module's internal code. Other modules are never allowed back there.
This guide explains the difference between internal and public APIs in a .NET modular monolith, why it matters so much, and how to build it step by step in plain words.
Two kinds of code in every module
Every module really has two layers of code.
The public API is the front desk. It is small and on purpose. It says: "Here is exactly what you may ask me to do." It is made of stable, simple things like interfaces, DTOs, commands, and events.
The internal code is everything behind the desk. It is the entities, the database logic, the EF Core DbContext, the validation, the private helper classes. It is where the real work happens, and it is allowed to change any time.
Here is the rule that holds it all together:
Other modules may use only your public API. They may never touch your internal code or your tables. If they need something, they ask the front desk.
Notice the dotted blocked arrows. The other module is not allowed to skip the front desk. That single rule is what makes a module a real module instead of just a folder with a nice name.
What the internal keyword actually does
C# gives us a small but powerful tool for this: the internal keyword.
When you mark a class public, any project that references your project can see it and use it. When you mark a class internal, it can only be seen inside the same project (assembly). Code in another project simply cannot reach it. It does not even appear in IntelliSense.
This is the secret to module boundaries. If each module is its own project, and almost everything in it is internal, then other modules physically cannot call your inner classes. The compiler stops them. You do not need to rely on people being careful or remembering a rule. The wall is real.
// Inside the Orders module project.
// This class does the real work, so it stays hidden.
internal sealed class OrderService
{
private readonly OrdersDbContext _db;
public OrderService(OrdersDbContext db) => _db = db;
public async Task<Guid> PlaceOrderAsync(Guid customerId, decimal amount)
{
var order = new Order(customerId, amount);
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order.Id;
}
}Another module that tries new OrderService(...) will not even compile. The class is invisible to it. That is exactly what we want.
The Contracts project: the official front desk
So if everything is internal, how does any module talk to another?
The answer is a small, separate project called Contracts (some teams call it the Public API project). This project holds only the safe, public things a module wants to share: interfaces, DTOs, commands, and events. Nothing else.
A typical module is split into two or three projects:
| Project | Visibility | What lives here |
|---|---|---|
Orders.Contracts | public | Interfaces, DTOs, events that other modules may use |
Orders (or Orders.Application) | mostly internal | The real logic, services, validators, handlers |
Orders.Infrastructure | internal | EF Core DbContext, entities, database code |
Only the Contracts project is referenced by other modules. The real logic and the database project are never referenced from outside. This is how we keep the front desk small and the staff areas private.
Here is what a clean public contract looks like. Notice it is tiny and uses simple types, not entities.
// In the Orders.Contracts project. This is the front desk.
public interface IOrdersModuleApi
{
Task<OrderSummary> GetOrderAsync(Guid orderId, CancellationToken ct = default);
}
// A DTO is a flat, simple shape. It is NOT the Order entity.
public sealed record OrderSummary(
Guid OrderId,
Guid CustomerId,
decimal Amount,
string Status);The OrderSummary record is a DTO (Data Transfer Object). It is a plain, flat copy of just the fields other modules are allowed to see. It is not the real Order entity, which stays internal. This means you can rename a column or add a private field inside Order without breaking anyone, as long as the DTO still looks the same.
Wiring it up with dependency injection
The interface lives in the public Contracts project, but the class that implements it stays internal inside the module. We connect the two using dependency injection (DI), which is built into .NET.
// Inside the Orders module. The implementation is internal...
internal sealed class OrdersModuleApi : IOrdersModuleApi
{
private readonly OrdersDbContext _db;
public OrdersModuleApi(OrdersDbContext db) => _db = db;
public async Task<OrderSummary> GetOrderAsync(Guid orderId, CancellationToken ct = default)
{
var order = await _db.Orders.FindAsync([orderId], ct)
?? throw new OrderNotFoundException(orderId);
return new OrderSummary(order.Id, order.CustomerId, order.Amount, order.Status.ToString());
}
}
// ...but we register it against the PUBLIC interface.
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(this IServiceCollection services)
{
services.AddScoped<IOrdersModuleApi, OrdersModuleApi>();
// register DbContext, validators, handlers, etc.
return services;
}
}Now the Billing module can ask for IOrdersModuleApi in its constructor and use it, without ever seeing OrdersModuleApi or OrdersDbContext. The front desk is open; the staff areas stay shut.
A cross-module call, step by step
Steps
Billing needs data
It does not own orders.
Inject interface
Only the public contract is visible.
Call the method
Billing uses the front desk.
Orders works
Internal logic and tables stay hidden.
Return DTO
A flat, safe copy comes back.
Two ways modules can talk
There are two main ways a module can use another module's public API. Both go through the front desk, but they feel different.
The first is a direct call, which we just saw. Billing calls IOrdersModuleApi.GetOrderAsync(...) and waits for an answer. This is simple and great when one module truly needs an answer right now to continue.
The second is events. Instead of calling another module, a module announces that something happened. For example, when an order is paid, the Orders module publishes an OrderPaid event. Other modules that care, like Shipping, listen and react on their own. Orders does not even know who is listening.
| Way to talk | What it looks like | Best when |
|---|---|---|
| Direct call | await ordersApi.GetOrderAsync(id) | You need an answer immediately |
| Event | Publish OrderPaid, others listen | You just want to announce news |
Events give you looser coupling, because the sender does not depend on the receiver at all. Direct calls are simpler but tie the two modules a little closer together. Many real apps use both.
A quick note on tools. Libraries like MediatR and MassTransit are popular for in-process messages and events. As of recent versions they have moved to a commercial license for many uses, so check the terms before adding them to a paid product. You do not need them to build a modular monolith. .NET has a built-in DI container and you can use a small custom event dispatcher, so plenty of teams start simple and add a library only if they really need it.
Why public APIs are not about removing coupling
Here is an idea that surprises many students.
A public API does not remove coupling between modules. If Billing calls Orders, the two are coupled. They depend on each other. That is real, and it is fine.
What the public API does is make that coupling visible and on purpose. Every public method and event is a signed promise that says: "Yes, these two modules are connected, and this is the exact, agreed way they connect." The coupling is now small, named, and easy to find. You can search your whole codebase for who uses IOrdersModuleApi and instantly know every module that depends on Orders.
Compare that to the messy version, where Billing reaches into the Orders tables with a raw SQL JOIN. Now the coupling is hidden. Nobody can see it by reading the front desk. One day someone renames an Orders column, and Billing breaks for a reason that takes hours to find.
Visible coupling vs hidden coupling
Steps
You change a table
Inside Orders only.
Public API path
DTO stays the same, nobody breaks.
Hidden JOIN path
Billing read the table directly.
Safe result
Boundary held, change was free.
Surprise break
Billing fails, hard to trace.
The goal of a modular monolith was never zero coupling. It is honest coupling that you can see, count, and control.
How to keep the boundary from leaking
Even with internal and a Contracts project, boundaries can still leak if you are not careful. Here are the simple habits that keep them strong.
Make internal the default, not public. When you create a new class inside a module, leave it internal unless you have a clear reason to share it. Be stingy with the front desk. A small public API is a happy public API.
Never put entities in the Contracts project. Contracts should hold only interfaces, DTOs, commands, and events. The moment your Order entity leaks into Contracts, other modules start depending on your database shape, and you lose your freedom to change it.
Give each module its own database schema. Even in one shared database, put Orders tables in an orders schema and Billing tables in a billing schema. This makes it obvious who owns what, and it blocks lazy cross-schema JOIN queries.
Add an architecture test. You can write an automated test that fails the build if any module references another module's internal project. This turns your rule into something the computer checks for you, every single time.
// Using NetArchTest to guard the boundary in a unit test.
[Fact]
public void Billing_should_not_depend_on_Orders_internals()
{
var result = Types.InAssembly(typeof(BillingModule).Assembly)
.That()
.ResideInNamespace("Billing")
.ShouldNot()
.HaveDependencyOn("Orders.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}A test like this is your night guard. People forget rules; the test never does. If someone sneaks a forbidden reference into the code, the build turns red and tells them exactly what they broke.
A small mistake to avoid
A common beginner mistake is to "just make it public for now" because the compiler is complaining.
Picture this. Billing needs one tiny field from an Orders entity. The quick fix is to mark the Order class public so Billing can read it. It compiles. The feature ships. Everyone is happy.
But now the front desk is gone for that entity. Billing reads Order directly. Six months later, the Orders team wants to split Order into two tables for performance. They cannot, because Billing is glued to the old shape. The five-minute shortcut became a five-day cleanup.
The right fix is almost as fast: add one small field to the OrderSummary DTO in Contracts, and let Orders fill it in. The entity stays internal. The boundary holds. Future-you says thank you.
The lesson is simple. When the compiler blocks a cross-module reference, that is not the compiler being annoying. That is your boundary working. Reach for a DTO or a new method on the public API, not for the public keyword on an entity.
Putting it all together
A healthy module in a .NET modular monolith looks like this. It has a tiny public Contracts project that holds interfaces, DTOs, and events. It has a larger internal project where the real logic lives, marked internal by default. It owns its own tables, ideally in its own schema. It talks to other modules through direct calls or events, never by touching their tables. And an architecture test stands guard so the rules are checked automatically.
When you follow this, your one application stays calm and easy to change, even as it grows. Each module can evolve behind its front desk without scaring its neighbours. And if one day a module needs to become its own microservice, the move is gentle, because the front desk is already the only door in.
Quick recap
- A public API is a module's small front desk: interfaces, DTOs, commands, and events that others may use.
- Internal code is everything behind the desk: entities, logic, and the
DbContext. It stays hidden and free to change. - The C#
internalkeyword makes inner types invisible outside their project, so the compiler enforces the boundary for you. - Put the public types in a separate Contracts project. Other modules reference only that.
- Implementations stay
internaland are wired to public interfaces through dependency injection. - Modules talk by direct calls (need an answer now) or events (just announcing news). Both go through the front desk.
- A public API does not remove coupling. It makes coupling visible, named, and safe.
- Keep boundaries strong: default to
internal, keep entities out of Contracts, use per-module schemas, and add architecture tests. - When the compiler blocks a cross-module reference, that is the boundary working. Add a DTO, do not make the entity
public.
References and further reading
- Internal vs. Public APIs in Modular Monoliths — Milan Jovanović
- Modular Monolith Architecture in .NET — Complete Guide — Milan Jovanović
- Microsoft .NET application architecture guidance
- Modular Monolith Architecture with .NET — ABP.IO
- Access modifiers (C# reference) — Microsoft Learn
Related Posts
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
How to Keep Your Data Boundaries Intact in a Modular Monolith (.NET)
Learn simple, practical ways to keep data boundaries strong in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins.
Building a Modular Monolith With Vertical Slice Architecture in .NET
Learn to build a modular monolith using vertical slice architecture in .NET. Simple words, real-life analogy, diagrams, tables, and clean C# code examples.
Where Vertical Slices Fit Inside the Modular Monolith
A simple guide to how vertical slices live inside the modules of a modular monolith in .NET, with diagrams, code, tables, and everyday examples.
Monolith to Microservices: How a Modular Monolith Helps
Learn how a modular monolith makes the move from monolith to microservices safe and easy in .NET, using clean boundaries, the Strangler Fig pattern, and small steps.
Breaking It Down: How to Migrate Your Modular Monolith to Microservices
A friendly, step-by-step guide to safely move from a .NET modular monolith to microservices using the Strangler Fig pattern, without a risky big-bang rewrite.