What a modular monolith module API consists of
A modular monolith module API is the only surface other modules are allowed to touch. It has three parts: a module facade (one or a few entry points exposing operations such as placeOrder or getInvoiceSummary), the request and response types those operations accept and return, and the event types the module publishes for others to react to. Anything not on that list is not part of the contract, however convenient it would be to import.
What stays private:
- Entities and aggregates, because their invariants depend on being changed only through the module's own logic.
- Repositories and ORM models, because exposing them hands other teams the table layout and the ability to query or write around your rules.
- Internal services, validators and mappers, because they are implementation choices you want to rename, merge or delete without coordinating a release.
Boundaries that exist only as folder names and conventions erode because the compiler does not see them. The first cross-module import of a repository usually comes from a deadline. It works, so it gets copied, and a few releases later the "billing" folder has callers in four other modules. Refactoring it now requires a cross-team migration. This is why the API needs real encapsulation through language visibility, internal packages and build checks, not a README.
Backend and platform engineers get the most from this when several teams share one codebase, or when one team's scope is growing quickly. In both cases the cost of an accidental coupling lands on someone other than the person who introduced it.
+-----------------+ +----------------------------------------------------+
| Orders module | | Billing module |
+--------+--------+ | PUBLIC API: only surface other modules may touch |
| | +---------------------+ +--------------------+ |
| calls | | BillingFacade | | Request / Response | |
+----------->| | placeOrder() | | types | |
| | getInvoiceSummary() | +--------------------+ |
| +------------------+--+ |
(events) | +--------------+ | |
Shipping module <---|--| Event types | | |
| +--------------+ | |
| - - - - - - - - - -| - PRIVATE: not importable - -|
| v |
| +----------------------------------------------+ |
| | Internal services, validators, mappers | |
| +----------------------------------------------+ |
| v v |
| +-------------------+ +---------------------+ |
| | Entities and | | Repositories and | |
| | aggregates | | ORM models | |
| +-------------------+ +-------^-------------+ |
| X |
+-----------------------------------X----------------+
+----------------+ |
| Reports module |----------------------------------------+
+----------------+ direct repository import fails build check
Other modules call the Billing facade with its request/response types and react to its published events, while entities, repositories, ORM models and internal services stay private, and a direct repository import from another module is rejected by a build check.
Designing the module facade around use cases
Start from the callers, not from the services you already have. List what other modules actually need from this one, such as "reserve stock for an order" or "get the price a customer would pay for a cart", and make each of those an operation on the facade. If you start from the internal service layer, you will expose InventoryRepository.findBySku and StockLevelCalculator.recompute. Callers then have to compose your internals in the right order, and that sequencing knowledge leaks out of the module.
Prefer coarse-grained operations. One call that reserves stock and returns a reservation ID is easier to keep stable than three calls that must happen inside the same transaction. Coarse operations also let you change the internal steps without touching any caller.
Return DTOs that the module owns, never ORM entities. An entity carries persistence behaviour along with it. A caller that touches a lazy-loaded collection after the session closes will fail at runtime, and a caller that mutates an entity inside an open session may write to your tables without you knowing. DTOs should be immutable and contain only what the use case needs. Callers should never see sessions, query objects, or pagination cursors tied to your schema.
Granularity is a trade-off. A facade that is too fine is just a pass-through: every internal method gets a public twin, and encapsulation exists only on paper. A facade that is too coarse becomes a god-interface. Dozens of unrelated callers depend on it, and every change to it is a negotiation. Draw the facade around a single business capability and expect to redraw it as your understanding of the domain improves.
package com.example.inventory.api;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
// Public API of the inventory module. Callers depend only on this package;
// repositories, JPA entities and calculators stay in inventory.internal.
public interface InventoryFacade {
// One coarse operation per caller use case. The order module asks to
// "reserve stock for this order", not to find SKUs, recompute levels and
// insert holds in the right order. Locking and the transaction stay inside.
Reservation reserveStockForOrder(ReserveStock command);
// Releases every hold for the reservation; callers never see stock rows.
void releaseReservation(UUID reservationId);
// Read model shaped for the storefront's availability badge, not the entity.
Availability availabilityFor(String sku);
// Immutable, module-owned DTOs (nested types are implicitly public static).
// No entities, lazy collections, sessions or schema-tied cursors cross here.
record ReserveStock(UUID orderId, List<Line> lines) {
public ReserveStock {
lines = List.copyOf(lines); // defensive copy keeps it immutable
}
}
record Line(String sku, int quantity) {}
// Only what the caller needs to act on: an ID to release later and the outcome.
record Reservation(UUID reservationId, Status status, Instant expiresAt) {}
enum Status { RESERVED, PARTIALLY_AVAILABLE, OUT_OF_STOCK }
record Availability(String sku, boolean inStock, int availableToPromise) {}
}
A Java inventory facade shaped around caller use cases, exposing coarse-grained operations and immutable module-owned records instead of repositories or ORM entities.
Hiding internals with language-level visibility
Visibility modifiers turn "please don't call this" into a compiler error. Each mechanism has a specific scope and a known escape hatch, so it is worth knowing exactly what you are getting.
| Mechanism | What it prevents | What it does not prevent |
|---|---|---|
| Java package-private | Access from other packages | Access from other classes in the same package; reflection on the classpath |
JPMS exports |
Compile-time and runtime access to non-exported packages, and deep reflection unless the package is opens |
--add-exports / --add-opens flags; code run on the classpath instead of the module path |
C# internal + separate assembly |
References from other assemblies | InternalsVisibleTo; reflection |
Go internal/ directory |
Imports from outside the parent directory tree | Imports from anywhere inside that tree; unsafe |
TypeScript exports map |
Deep imports through the package name | Relative paths into another module's source; tsconfig path aliases |
Package-private is the weakest option because a module usually spans several packages. To share types between those packages, you have to make them public, and then every other module can reach them too. JPMS, assemblies, and Go's internal/ draw the boundary at the module level, which is the level you actually care about.
The bypasses tend to follow a pattern. Test projects get InternalsVisibleTo or a shared fixtures library that re-exports internals, and production code later starts importing those fixtures. Someone adds an --add-opens flag or a relative import to meet a deadline, and nobody removes it. None of these show up as errors. They are deliberate overrides, so the build cannot catch them on its own, and you need the checks covered in the next section.
// billing/src/main/java/module-info.java
module com.example.billing {
// The facade: the only package other modules can compile against.
exports com.example.billing.api;
// Qualified export: visible to one named module, not to everyone.
exports com.example.billing.events to com.example.notifications;
// Deliberately NOT exported and NOT opened:
// com.example.billing.internal (repositories, pricing rules)
// Other modules get a compile error if they import it, and
// reflective access (including setAccessible) fails at runtime.
requires com.example.shared.money;
}
// Escape hatches that silently undo the boundary. None of them is a
// build error, so look for them in launch scripts and build files:
//
// java --add-exports com.example.billing/com.example.billing.internal=com.example.orders ...
// java --add-opens com.example.billing/com.example.billing.internal=ALL-UNNAMED ...
//
// Putting billing.jar on the classpath instead of the module path
// also bypasses the boundary. The module descriptor is then ignored
// and every public class in every package is reachable again.
A JPMS module descriptor that exports only the billing facade package and a qualified events package, with comments showing the --add-exports, --add-opens and classpath bypasses that defeat it without causing build errors.
Enforcing boundaries in the build and CI
described here to run on every build rather than relying on code review.
Architecture tests
On the JVM, ArchUnit-style tests load the compiled classes and assert dependency rules as ordinary unit tests. A typical rule says that no class outside billing may depend on anything in billing.internal, or that only classes in a module's api package may be referenced from other modules. These tests catch the overrides described above because they inspect actual bytecode dependencies, not intentions. Write them once per convention, not once per module, so a new module is covered automatically when it follows the package layout.
Separate build modules
Splitting each module into its own build module or subproject goes further. The compiler cannot see a dependency that is not declared, so orders cannot import billing internals unless the build file lists them. Some teams split each module into an API artifact and an implementation artifact, and other modules may depend only on the API. The cost is real. More build modules add configuration overhead, can lengthen clean builds and dependency resolution, and make IDE indexing and project import slower and more fragile. A dozen modules is usually manageable. Hundreds of fine-grained ones usually are not.
CI import checks
For languages without strong visibility, a CI step can scan for imports matching another module's internal path and fail the build. Keep the rule list in the repository so that adding an exception requires a reviewed change and is never done silently.
package com.example.shop.architecture;
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;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
// Runs as an ordinary JUnit 5 test on every build. ArchUnit reads the compiled
// bytecode, so it checks real dependencies, not what the code is meant to do.
@AnalyzeClasses(
packages = "com.example.shop",
importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// Nothing outside billing may reach into billing's internals.
@ArchTest
static final ArchRule billingInternalsAreHidden =
noClasses().that().resideOutsideOfPackage("com.example.shop.billing..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.shop.billing.internal..")
.because("other modules must go through the billing facade in billing.api");
// The same rule from the other side: only billing may depend on billing.internal.
@ArchTest
static final ArchRule onlyBillingUsesItsInternals =
classes().that().resideInAPackage("com.example.shop.billing.internal..")
.should().onlyHaveDependentClassesThat()
.resideInAPackage("com.example.shop.billing..");
// Orders may use billing only through its published API package.
@ArchTest
static final ArchRule ordersUsesOnlyBillingApi =
noClasses().that().resideInAPackage("com.example.shop.orders..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.shop.billing.internal..");
}
An ArchUnit JUnit 5 test that fails the build when any class outside the billing module depends on billing's internal package, which leaves billing.api as the only allowed entry point.
Data ownership and cross-module communication
Code boundaries mean little if the database lets any module read any table. Each module should own its tables, ideally in a separate schema per module, and no other module queries them directly. If the billing module joins against the orders table, a column rename in orders breaks billing, and the facade was never consulted. Cross-module reads go through one of three routes: a facade call, a published event, or a read model that the consuming module builds from those events.
| Concern | Synchronous facade call | In-process event |
|---|---|---|
| Fits when | The caller needs the answer now to continue | The publisher doesn't care who reacts |
| Coupling | Caller depends on the callee's API and availability | Subscriber depends only on the event's shape |
| Tracing and debugging | Linear stack trace, easy to step through | Handlers run out of line, so you need correlation IDs and logging |
| Transactions | Can share the caller's transaction | Depends on dispatch: before or after commit |
Event dispatch timing matters when both modules share one database. If handlers run inside the publisher's transaction, a failing handler rolls back the publisher's write, which reintroduces coupling. If they run after commit, a crash between commit and dispatch loses the event unless you persist it first, for example in an outbox table.
The cost is real. You lose convenient joins, and reporting that spans modules needs duplicated read models. Those models lag behind the source, so dashboards must tolerate eventual consistency.
Evolving a module API and migrating a tangled codebase
Inside one repository, a module API still needs versioning discipline, even though everything ships together. Prefer additive changes: new methods, new optional fields, new event types. When a signature has to change, add the new form beside the old one, mark the old one deprecated, and remove it only after the last caller has moved. The providing module should own contract tests that exercise its facade the way consumers use it. Then a change that breaks a consumer fails in the provider's own build, before anyone else's.
"We can just refactor all callers" works when one person knows every caller. As the team grows, callers sit in modules owned by other people with their own priorities and review queues. A breaking change turns into a coordinated edit across several teams, and it stalls on the slowest reviewer. Deprecation windows let each consumer migrate on its own schedule.
Migrating a tangled codebase
- Map current dependencies. Use static analysis or import graphs to list which modules reach into which internals, and how often.
- Introduce facades at the busiest seams first. A facade over the most-imported internals removes the most coupling per unit of effort.
- Redirect callers to the facade, one module at a time.
- Tighten visibility once a package has no external callers left.
- Turn on enforcement with an explicit exception list. Every entry needs an owner and a reason. CI should reject new entries, and the list should shrink each release. Without a named owner, an exception tends to stay on the list indefinitely.
When to use it, when to avoid it, and impact on cost and engineering time
Module APIs pay off when several teams commit to the same codebase, or when the domain is stable enough that you can name capabilities like billing, catalog, or identity without redrawing them every quarter. Defer them in small or early-stage codebases. There, every change pays for facade methods and DTO mapping, and the boundaries you draw before the domain is understood will often be wrong. Once callers depend on a published facade, moving a responsibility means migrating those callers, so an early mistake hardens instead of getting cheap to fix.
| Approach | Boundary enforcement | Upfront cost | Main risk |
|---|---|---|---|
| Plain layered monolith | None between features | Lowest | Any code can reach any table or class, so coupling grows unnoticed |
| Convention-only modules | Code review and folder names | Low | Shortcuts accumulate under deadline pressure |
| Modules with enforced APIs | Visibility rules plus CI checks | Moderate | Wrong boundaries fixed in place too early |
| Separate services | Network and deployment | Highest | Distributed failures, versioning, and operational overhead |
The time savings come from containment, not speed of typing. A change behind a facade touches one module's internals and tests. Callers don't move, and reviewers from other teams aren't needed. Two teams can rework their internals in parallel because breakage can only travel through the contract. Coordination still happens, but only when a contract changes, and those changes are rarer and show up plainly in a diff.
Clean module APIs also keep a later service extraction possible. Treat that as a side benefit. The main reason to adopt them is to make the monolith you already run cheaper to change.
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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.