What a modular monolith module API is and why it matters
A modular monolith module API is the small, deliberate surface a module exposes to the rest of the codebase. It has two parts: a facade interface (the methods other modules may call) and the contract types that cross the boundary, meaning request objects, response objects and published events. Handlers, repositories, entities, mappers and the persistence schema stay internal. Other modules cannot see them, so they can change without notice.
+---------------------------+ +---------------------------+
| Orders module | | Billing module |
| [public] | | [public] |
| OrdersFacade |<--------| BillingFacade |
| PlaceOrderRequest/Result| call | InvoiceRequest/Result |
| OrderPlaced (event) | | InvoicePaid (event) |
| [internal] | | [internal] |
| handlers | | handlers |
| repositories, entities | | repositories, entities |
| schema: orders | | schema: billing |
+-------------+-------------+ +-------------+-------------+
| publish / subscribe |
v v
+-----------------------------------------------+
| in-process event bus (same runtime) |
+-----------------------------------------------+
The purpose is to keep each module changeable and, if needed later, extractable. Treat the facade as you would a network API: use explicit contracts, return no internal entities, avoid leaking ORM objects and avoid chatty call patterns. Calls stay in-process, so you do not pay for serialization, network latency or partial-failure handling. If extraction ever happens, it mostly means swapping the facade's implementation for a client.
This approach suits teams that have several distinct domains in one codebase and that do not yet need independent deployment, or are not ready for it.
Be clear about what a facade does not provide. Modules still ship as one artifact, scale together and fail together. A memory leak in Billing still takes down Orders. The facade buys you code-level isolation, not operational isolation.
+--------------------------------+ +--------------------------------+
| Orders module | | Billing module |
| [public API] | | [public API] |
| OrdersFacade |<-----| BillingFacade |
| PlaceOrderRequest / Result | call | InvoiceRequest / Result |
| OrderPlaced (event) | | InvoicePaid (event) |
|- - - - - - - - - - - - - - - - | |- - - - - - - - - - - - - - - - |
| [internal] | | [internal] |
| handlers | | handlers |
| repositories, entities | | repositories, entities |
| | | | | |
| v | | v |
| +--------------+ | | +--------------+ |
| | schema: | | | | schema: | |
| | orders | | | | billing | |
| +--------------+ | | +--------------+ |
+---------------+----------------+ +---------------+----------------+
| |
| publish / subscribe | publish / subscribe
v v
+------------------------------------------------------------------------+
| in-process event bus (same runtime, same process) |
+------------------------------------------------------------------------+
Two modules in one process, each exposing only a facade and its contract types while handlers, repositories, entities and schema stay internal, with events flowing through a shared in-process bus.
Designing facade methods and contract types
A facade method should name something the calling module wants to accomplish, not a table it wants to touch. reserveStock(orderId, sku, quantity) lets Inventory decide how reservations are stored, locked and expired. A pair of calls like getStockRow(sku) followed by updateStockRow(row) pushes that logic into Orders. Inventory can then no longer change its schema or invariants without breaking a caller it does not own. Queries work the same way. getCustomerSummary(customerId) returns exactly what other modules need to display or decide, and nothing more.
Return types are where most facades leak. Returning an entity hands the caller lazy-loaded associations, setters and persistence state. Once that happens, the caller depends on your ORM mapping and can mutate your data outside your transaction rules. Contract types should be immutable DTOs or read models that hold IDs and primitive or value data, such as money, dates and status enums. When a contract mentions data from another module, reference it by ID instead of embedding an object graph. A CustomerSummary carries an accountManagerId, not an Employee object. That keeps each module's types from spreading across every other module.
Granularity is a trade-off in both directions:
- Too fine-grained: methods become CRUD over someone else's data. Callers make chatty sequences of calls and reassemble business rules themselves.
- Too coarse: you get god methods with flags and optional parameters, which change whenever any caller needs something new.
A reasonable test is whether each method maps to one caller-visible use case that the owning module can implement in a single transaction.
The example below contrasts a leaky facade with a use-case facade.
// LEAKY: Orders gets Inventory's entity and does the reservation logic itself.
interface InventoryFacadeLeaky {
getStockRow(sku: string): Promise<StockEntity>; // exposes ORM entity, setters, lazy relations
updateStockRow(row: StockEntity): Promise<void>; // caller now owns Inventory's invariants
}
// USE-CASE: Inventory owns how reservations are stored, locked and expired.
type ReservationResult =
| { readonly status: "RESERVED"; readonly reservationId: string; readonly expiresAt: Date }
| { readonly status: "INSUFFICIENT_STOCK"; readonly available: number };
interface InventoryFacade {
// One caller-visible use case, implemented in one Inventory transaction.
reserveStock(orderId: string, sku: string, quantity: number): Promise<ReservationResult>;
}
// Read model for other modules: IDs plus primitive/value data, nothing persistent.
interface Money { readonly amountMinor: number; readonly currency: string }
interface CustomerSummary {
readonly customerId: string;
readonly displayName: string;
readonly tier: "STANDARD" | "GOLD";
readonly creditLimit: Money;
readonly accountManagerId: string; // reference by ID, not an Employee object
}
interface CustomerFacade {
getCustomerSummary(customerId: string): Promise<CustomerSummary>;
}
// Placeholder for Inventory's internal ORM entity; never exported in a real module.
declare class StockEntity { sku: string; onHand: number; reserved: number }
A leaky facade that hands out a mutable stock entity, contrasted with use-case facades that return immutable DTOs and refer to other modules' data by ID.
Enforcing the boundary with the build, not convention
A facade only protects anything if callers cannot go around it. Conventions such as "don't import from orders.internal" hold until someone needs a value quickly and an IDE auto-import offers the internal class. Code review rarely catches this. The violating line is a single import in a diff about something else, and reviewers read for behaviour, not dependency direction.
Each language offers a different level of help at compile time:
- Go has unexported identifiers, which are package-scoped, and
internal/directories, which the toolchain refuses to import from outside the parent tree. - C# scopes
internalto an assembly, so one project per module gives real privacy. - Java package-private stops at a single package. That is too narrow for a module with sub-packages, so you need build-level splitting or the module system's
exports.
Separate build modules with explicit dependency declarations make a forbidden edge fail compilation, and they reveal cycles immediately.
Where the language cannot express module-level privacy, architecture tests become the main guard. These are rules run in CI that fail when code outside billing depends on anything except billing.api. They are cheap to add, but they are just tests. Under deadline pressure, someone can add them to an exclusion list or skip them with a single commit.
| Mechanism | Strength | Cost | Ease of bypass |
|---|---|---|---|
| Language visibility | High within its scope | Near zero | Hard; requires changing the modifier |
| Separate build modules/projects | High | Build config, slower setup | Moderate; add a dependency line |
| Architecture tests in CI | Medium | Low to write, needs upkeep | Easy; skip or exclude |
| Code review | Low | Reviewer attention | Very easy; unnoticed |
package com.example.architecture;
import static com.tngtech.archunit.base.DescribedPredicate.not;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
// ArchUnit scans compiled classes and fails the CI build on forbidden edges.
// Production code only, so test helpers can still reach into internals.
@AnalyzeClasses(
packages = "com.example",
importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// Code outside billing may depend on billing.api and nothing else in billing.
// Java package-private cannot express this when billing has sub-packages.
@ArchTest
static final ArchRule billingIsOnlyReachedThroughItsApi =
noClasses()
.that().resideOutsideOfPackage("com.example.billing..")
.should().dependOnClassesThat(
resideInAPackage("com.example.billing..")
.and(not(resideInAPackage("com.example.billing.api.."))))
.because("other modules must call billing through its facade");
// Repeat the rule for each module, or generate rules from a module list.
// Treat changes to this file like changes to the build: they need an owner's approval,
// because deleting or narrowing a rule here is how the boundary quietly erodes.
}
An ArchUnit rule, run as a JUnit 5 test in CI, that fails the build when any class outside the billing module depends on billing types other than those in billing.api.
Inter-module communication: synchronous calls vs in-process events
Once a boundary is enforced, modules still need to talk. Use a direct facade call when the caller needs an answer to continue: checking stock before accepting an order, or pricing a cart. Use an in-process event when the caller is only announcing that something happened and does not care who reacts, such as OrderPlaced triggering a confirmation email or a loyalty-points update.
| Concern | Synchronous facade call | In-process event |
|---|---|---|
| Coupling | Caller depends on the callee's API | Publisher does not know its subscribers |
| Transaction scope | Can share the caller's transaction | Should run after commit, in its own transaction |
| Debuggability | Linear stack trace, easy to step through | Control flow spread across handlers |
| Later extraction | Becomes an RPC call; latency and failure modes change | Maps naturally onto a message broker |
Events have real costs. You cannot see the flow by reading the publisher, so finding out what OrderPlaced triggers means searching for subscribers. Timing is the bigger risk. If handlers run inside the transaction, a later rollback leaves side effects for an order that never existed. If you publish after commit with no durable record, a crash between commit and dispatch loses the event.
The safer pattern is to register the event with the unit of work and dispatch it only after a successful commit. Where losing an event is unacceptable, write it to an outbox table in the same transaction and have a relay deliver it. Delivery is then at-least-once, so handlers must be idempotent.
type DomainEvent = { type: string; payload: unknown };
type Handler = (e: DomainEvent) => Promise<void>;
interface Tx { query(sql: string, params: unknown[]): Promise<void>; }
interface Db { transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>; }
class EventBus {
private handlers = new Map<string, Handler[]>();
subscribe(type: string, h: Handler) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), h]);
}
async dispatch(e: DomainEvent) {
for (const h of this.handlers.get(e.type) ?? []) {
// A failing subscriber must not affect the publisher's committed work.
await h(e).catch((err) => console.error(`handler failed for ${e.type}`, err));
}
}
}
class UnitOfWork {
private pending: DomainEvent[] = [];
constructor(private db: Db, private bus: EventBus) {}
record(e: DomainEvent) { this.pending.push(e); } // queue only, never dispatch here
async run<T>(work: (tx: Tx, uow: UnitOfWork) => Promise<T>): Promise<T> {
this.pending = [];
// If work throws, the transaction rolls back and run() exits: queued events are dropped.
const result = await this.db.transaction((tx) => work(tx, this));
const events = this.pending;
this.pending = [];
// Reached only after a successful commit. A crash right here still loses events;
// use an outbox table written inside the transaction if that is unacceptable.
for (const e of events) await this.bus.dispatch(e);
return result;
}
}
// Orders module announces OrderPlaced; email and loyalty modules subscribe independently.
async function placeOrder(uow: UnitOfWork, orderId: string, customerId: string) {
await uow.run(async (tx, u) => {
await tx.query("INSERT INTO orders (id, customer_id) VALUES ($1, $2)", [orderId, customerId]);
u.record({ type: "OrderPlaced", payload: { orderId, customerId } });
});
}
A minimal unit of work that queues in-process events during a transaction and dispatches them only after a successful commit, so a rollback never triggers subscriber side effects.
Data ownership, cross-module reads and transactions
Each module owns its tables, ideally in its own schema, and no other module reads or writes them directly. The rule is easy to state and easy to break, because a cross-module join is one line of SQL. Once another module's query depends on your column names, you can no longer change your storage without coordinating with that module, and the facade stops being the real boundary.
Cross-module reads
List screens and reports are where the rule gets tested first. There are three workable options:
- Compose through facades. Fetch IDs from one module, then batch-load details from another. This keeps ownership intact, but you lose the database's join planner and must avoid N+1 calls by adding batch methods to the facade.
- A dedicated query module. It may read several schemas, but it is read-only and nothing depends on it. This trades purity for SQL convenience and puts the coupling in one place you can find.
- Replicated read models. A module subscribes to events and keeps its own denormalized copy. Joins are local and fast, but the copy is eventually consistent, so the UI has to tolerate stale rows.
Transactions
When one facade call runs inside the caller's transaction, both modules commit or roll back together. That is convenient, but it is hidden coupling. Nothing in the method signature shows it, and it disappears the day one module moves to its own process. If you keep a separate transaction per module and publish changes through the outbox, the code is already shaped like a distributed system. Extracting a module later then changes the transport, not the consistency model.
Evolving and testing a module API inside one repository
Inside a single repository, a module API doesn't need version numbers, because every caller is compiled in the same build. Formal versioning exists to protect consumers you can't see. Here you can see all of them, so the job is to change the facade without breaking any caller in a single commit.
Prefer additive changes. Add a new method or an optional field rather than changing an existing signature. When a method has to go, mark it deprecated and point to its replacement. Then let the compiler list every remaining caller as a warning. Migrate those callers, and delete the old method once the warnings stop. If your build treats deprecation warnings in a module's own code as errors, no new callers can appear during the migration.
Test each module in isolation against fakes of the other modules' facades, not against their real implementations. A fake is a small in-memory class that implements the facade interface. With fakes, a billing test never depends on how the inventory module stores its data, and the test runs without that module's database.
A fake only helps if it behaves like the real thing. To check that, write one contract test suite against the facade interface and run it twice: once with the real facade wired to a test database, and once with the fake. The suite pins observable behavior:
- what an unknown ID returns
- which error a duplicate request raises
- whether results come back ordered
It never asserts on tables or internal classes, so the owning module can refactor freely. If the real facade's behavior changes, the contract suite fails and the fake has to be updated to match. That keeps every consumer's tests honest.
When to use it, when to avoid it, alternatives and cost
Migrating a tangled codebase
Start by mapping what already exists. Build a dependency graph from imports and list every place where one module reads another's tables or internal classes. Pick the module with the most inbound dependencies and give it a facade that covers only the calls already in use. Then repoint callers to the facade. Run architecture tests in warning mode first, so the backlog is visible without blocking merges. Switch each rule to failing once its violation count reaches zero. This ratchet stops new leaks while you remove old ones incrementally.
When to skip strict facades
A module that is thin CRUD over one table gains little from DTOs that copy fields one-to-one. That mapping is ceremony. Where the domain is still being discovered, hard contracts freeze boundaries around concepts that may turn out to be wrong. Keep that code together until the seams stop moving.
| Approach | Boundary enforced by | Operational cost | Cost to extract later |
|---|---|---|---|
| Convention-only layered monolith | Code review | Low | High |
| Shared-database modules | Code structure only | Low | High (data untangling) |
| Facade-based modular monolith | Build and tests | Low | Moderate |
| Separate services | Network | High (deploys, partial failures, tracing) | Already paid |
Cost
Illustrative estimate (assumed numbers, not a benchmark):
net hours per quarter = (cross-module refactors × hours saved each) − (facade methods added × mapping hours each)
Assume 6 refactors that each save 8 hours, and 40 new methods at 0.5 hours each. That gives 48 − 20 = 28 hours.
This formula leaves out parallel team work and avoided distributed-system overhead, so it understates the benefit. If the result is negative for a module, that module is a candidate for a lighter boundary.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to modular monolith module api, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.