What modular monolith communication is and why boundaries matter more than call style

A modular monolith is a single deployable unit split into modules with enforced boundaries. Each module owns a business capability, its data, and a small public surface. Everything else is private. Modules talk to each other in two ways:

  • Synchronous calls through a module API. The caller invokes a public interface and waits for a result in the same process and usually the same transaction.
  • In-process events. A module publishes a fact, such as "order placed," to an in-memory bus. Other modules subscribe and react without the publisher knowing who they are.
Module Public API Private internals Owned tables
Orders OrdersApi services, entities, repositories orders, order_lines
Billing BillingApi services, entities, repositories invoices, payments
Inventory InventoryApi services, entities, repositories stock_levels
Shared In-process event bus: modules publish to it and subscribe through it, never through each other's internals

This guide argues that choosing between calls and events matters less than whether the boundary holds. Most coupling comes from leaks. Common examples are an entity class shared by two modules, a SQL join across another module's tables, and a repository exposed "just for reads." Each one ties a module to another module's internal schema and object model. A rename or migration then breaks code the owning team cannot see. Switching those dependencies from calls to events does not fix this, because the coupling sits in the shared data shape.

The guide is for backend and platform engineers who ship one deployable and want modules that can be changed, tested, and extracted independently. The goal is to get those benefits without the network failures, partial outages, and operational cost of a distributed system.

 ONE DEPLOYABLE, ONE PROCESS: modules talk only through APIs or the bus
 +--------------------+     +--------------------+     +--------------------+
 | BILLING            |     | ORDERS             |     | INVENTORY          |
 |--------------------|     |--------------------|     |--------------------|
 | BillingApi (pub)   |<----| OrdersApi (pub)    |---->| InventoryApi (pub) |
 |--------------------|     |--------------------|     |--------------------|
 | private:           |     | private:           |     | private:           |
 |   services         |     |   services         |     |   services         |
 |   entities         |     |   entities         |     |   entities         |
 |   repositories     |     |   repositories     |     |   repositories     |
 |--------------------|     |--------------------|     |--------------------|
 | owns tables:       |     | owns tables:       |     | owns tables:       |
 |   invoices         |     |   orders           |     |   stock_levels     |
 |   payments         |     |   order_lines      |     |                    |
 +--------------------+     +--------------------+     +--------------------+
          |  ^                       |  ^                       |  ^
          v  |                       v  |                       v  |
 +--------------------------------------------------------------------------+
 | In-process event bus: OrderPlaced, InvoicePaid, StockReserved            |
 | publishers never know subscribers; handlers use only the event payload   |
 +--------------------------------------------------------------------------+

 ---->  sync call into another module's public API (same process/transaction)
 v  ^   publish / subscribe in-process events via the bus
 Not allowed: shared entities, cross-module joins, exposed repositories

Each module in the single deployable exposes only a public API, keeps its services, entities and repositories private, and owns its tables. Modules interact through synchronous API calls or an in-process event bus, never through each other's internals.

Defining and enforcing a module's public interface

A module's public interface should be small and deliberate. In practice that means one facade or service interface per module, plus the request and response DTOs the module defines for itself. Entities, repositories, domain services, and the tables behind them all stay private. If another module needs data, it asks through the facade. It does not query the module's tables or receive one of its entities.

Design the contract as if a network sat between the modules. Use coarse-grained operations such as "place order" or "get customer summary" rather than chatty getters. Return plain DTOs that are fully populated, never lazily loaded object graphs that fire queries after the call returns. Keep ORM annotations, persistence IDs, and framework types out of the signatures. If you later extract a module into its own service, a contract like this can become a remote API without being redesigned.

Enforcement takes more than one layer:

  • Language visibility: make internals package-private, internal, or non-exported, so other modules can't compile against them.
  • Structure: put each module in its own package tree or build project, with the API in a separate package or project that other modules depend on.
  • Architecture tests in CI: declare the allowed dependencies between modules and fail the build when a module imports another module's internals.

This costs something. Every boundary needs mapping code between entities and DTOs, and every call goes through a layer of indirection. That is still cheaper than relying on convention alone: under deadline pressure, a direct repository call across modules looks harmless, and after enough of those shortcuts the boundaries stop existing.

// --- orders/api: the only types other modules may compile against ---
// (each type lives in its own file under com.example.shop.orders.api)
public interface OrderFacade {
    // Coarse-grained operation that returns a fully populated DTO, never an entity.
    OrderSummary placeOrder(PlaceOrderRequest request);
}
public record PlaceOrderRequest(String customerId, List<OrderLine> lines) {}
public record OrderLine(String sku, int quantity) {}
public record OrderSummary(String orderId, String status, long totalCents) {}

// --- ModuleBoundaryTest.java: runs with the normal test suite, so CI fails on violations ---
package com.example.shop;

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.noClasses;

@AnalyzeClasses(packages = "com.example.shop")
class ModuleBoundaryTest {

    // Other modules may use orders.api but never entities or repositories in orders.internal.
    @ArchTest
    static final ArchRule ordersInternalsArePrivate =
        noClasses().that().resideOutsideOfPackage("com.example.shop.orders..")
            .should().dependOnClassesThat().resideInAPackage("com.example.shop.orders.internal..")
            .because("other modules must call OrderFacade, not orders internals");

    // Keep persistence types out of the contract so it could become a remote API later.
    @ArchTest
    static final ArchRule ordersApiHasNoPersistenceTypes =
        noClasses().that().resideInAPackage("com.example.shop.orders.api..")
            .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..")
            .because("the public contract must not leak ORM types");
}

This example shows a small orders facade with its own DTOs and two ArchUnit rules that fail the CI build when another module reaches into orders internals or when the public API depends on JPA types.

When to use synchronous module API calls and when to use in-process events

The decision rule is about who needs the outcome. Use a synchronous call through the other module's API when the caller cannot proceed without an answer, or when it must fail if the other module fails. Before the Orders module creates an order, it calls customers.getCustomerStatus(customerId). If the customer is suspended or the lookup throws, the order should not exist, and a direct call makes that dependency explicit in the code.

Use an in-process event when the publisher's job is already done and other modules only react. After the order is saved, Orders publishes OrderPlaced, and Notifications sends a confirmation email. Orders does not care whether the email succeeds, so it should not wait on it or know it exists. Name events as past-tense facts owned by the publisher: OrderPlaced, not SendOrderEmail. A command-style name means the publisher is still directing the work, which is a synchronous call written as an event.

Events have costs. They hide control flow, because reading the publisher no longer tells you what happens next. Finding handlers needs IDE support, a registry or a naming convention. With several handlers on one event, it is easy to assume one runs before another, and that assumption breaks without warning when registration order changes.

Concern Synchronous module API call In-process event
Consistency Caller sees the result immediately Reactions happen after publish
Failure behavior Callee failure propagates to caller Handler failure is isolated from publisher
Discoverability Follow the call in the code Search for subscribers
Coupling Caller depends on callee's API Subscribers depend on publisher's event
// Customers module public API: the only surface Orders may call.
interface CustomersApi {
  getCustomerStatus(customerId: string): Promise<'active' | 'suspended'>;
}

// Event owned by Orders, named as a past-tense fact.
type OrderPlaced = { orderId: string; customerId: string };
type Handler = (e: OrderPlaced) => Promise<void>;

// Minimal in-process bus that isolates handler failures from the publisher.
class EventBus {
  private handlers: Handler[] = [];
  subscribe(h: Handler) { this.handlers.push(h); }
  async publish(e: OrderPlaced) {
    for (const h of this.handlers) {
      try { await h(e); } catch (err) { console.error('handler failed', err); }
    }
  }
}

class OrdersService {
  constructor(private customers: CustomersApi, private bus: EventBus) {}

  async placeOrder(orderId: string, customerId: string) {
    // Synchronous call: no order may exist unless this succeeds.
    // A thrown error propagates and aborts the order.
    const status = await this.customers.getCustomerStatus(customerId);
    if (status !== 'active') throw new Error('customer not active');

    await saveOrder(orderId, customerId); // Orders' job is done here.

    // Event: Orders does not know or care who reacts.
    await this.bus.publish({ orderId, customerId });
  }
}

// Notifications subscribes; its failure never undoes the order.
// bus.subscribe(async (e) => sendConfirmationEmail(e.customerId, e.orderId));

declare function saveOrder(orderId: string, customerId: string): Promise<void>;

Orders calls the Customers API synchronously to validate before saving, then publishes a past-tense OrderPlaced event that Notifications handles without affecting the order's outcome.

How in-process events interact with transactions

An event does not decouple a handler from the publisher's transaction. When the handler runs relative to the commit decides which failures you can get, so that timing should be a deliberate choice for each subscription.

Handler timing What can go wrong Consistency readers see
Inside the publisher's transaction A handler exception rolls back the publisher's own state change Atomic, but modules are coupled through failure
Before commit, outside the transaction The handler acts on data that later rolls back, such as an email sent for an order that never existed Reactions can outlive the cause
After commit The handler fails or the process dies before it runs Eventually consistent, even within one process

After-commit handlers are usually the right default. They have two costs. First, a reader can briefly see an order with no matching invoice, so the UI and queries must tolerate that gap. Second, a failed handler needs retries, and retries need idempotent handlers. Any handler that can run twice must produce the same result as running once.

A plain after-commit dispatch keeps events in memory, so a crash between commit and dispatch loses them silently. If a reaction must survive that, use an outbox. The publisher writes the event into a table it owns, in the same transaction as the state change. A dispatcher then reads unprocessed rows, invokes the handlers, and marks the rows done.

The outbox has real costs. You run and monitor a dispatcher. Delivery becomes at-least-once, so you must handle duplicates. You also have to prune the table, watch for stuck rows, and replay them. For reactions that can be lost without harm, such as cache warming or analytics counters, plain after-commit handlers are cheaper and sufficient.

-- Outbox owned by the orders module (PostgreSQL).
CREATE TABLE orders.outbox (
  id           bigserial   PRIMARY KEY,
  event_type   text        NOT NULL,
  payload      jsonb       NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz            -- NULL until dispatched
);
CREATE INDEX outbox_pending ON orders.outbox (id) WHERE processed_at IS NULL;

-- Publisher: the state change and the event commit or roll back together.
BEGIN;
INSERT INTO orders.orders (id, customer_id, total_cents)
VALUES ('ord_1042', 'cus_77', 4999);
INSERT INTO orders.outbox (event_type, payload)
VALUES ('OrderPlaced', '{"orderId": "ord_1042", "totalCents": 4999}');
COMMIT;

-- Dispatcher: claim a batch. SKIP LOCKED lets multiple dispatchers run
-- without picking up the same rows.
BEGIN;
SELECT id, event_type, payload
FROM orders.outbox
WHERE processed_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- ...application code invokes handlers for each claimed row...
UPDATE orders.outbox SET processed_at = now()
WHERE id = ANY ('{1,2,3}'::bigint[]);   -- ids that were handled
COMMIT;

-- Consumer (billing): a crash before the UPDATE redelivers events, so the
-- handler records event ids (event_id is the primary key) in the same
-- transaction as its own work. Zero rows inserted means already handled.
INSERT INTO billing.processed_events (event_id) VALUES (1)
ON CONFLICT (event_id) DO NOTHING;

A PostgreSQL outbox in which the order and its OrderPlaced event commit atomically, a dispatcher claims pending rows with FOR UPDATE SKIP LOCKED, and the consumer deduplicates by event id to handle at-least-once delivery.

Data ownership, read models and breaking dependency cycles

Each table has exactly one owning module, and only that module writes to it. Other modules get the data in one of two ways. They can call the owner's API, or they can maintain a local read model that they build from the owner's events. Cross-module SQL joins and shared ORM entities are not allowed. A join couples you to another module's schema, so renaming a column turns into a coordinated change across teams. A shared entity is worse, because it lets any module mutate state that the owner believes it controls.

This rule has costs. Queries that used to be one join now become an API call followed by in-memory composition, and someone has to write and maintain that composition code. You can no longer wrap writes to two modules in one transaction. Instead you design for eventual consistency, using the outbox pattern from the previous section. Read models can also lag behind the source, so every screen that uses one needs an explicit decision about how much staleness is acceptable.

Detecting and breaking cycles

Build a graph of which modules reference which, using imports, API calls and event subscriptions, and fail the build when the graph contains a cycle. A cycle means neither module can be understood, tested or extracted without the other.

There are three ways to break a cycle:

  • Extract the shared concept. If both modules depend on the same idea, such as pricing rules, move it into a new module that both of them depend on.
  • Invert the dependency. The consuming module defines an interface describing what it needs, and the other module implements it. The compile-time arrow now points the other way.
  • Replace one direction with an event. Instead of calling back into the other module, publish an event and let the other module subscribe. This removes the synchronous coupling, but it also introduces asynchronous consistency.

Testing modules in isolation and across boundaries

Because each module exposes only a small public interface, unit-testing one module mostly means replacing its neighbours with fakes. If the Orders module depends on a PricingApi with two methods, a hand-written in-memory fake that returns fixed prices is a few lines and needs no database, container or network. A narrow interface is what keeps fakes cheap. When a module reaches into another's repositories or entities, the fake has to reproduce internal behaviour, and the test ends up coupled to code it was supposed to isolate. For events, give the module under test a recording publisher and assert on what it emitted. Do not run the subscribers.

Fakes cannot catch failures that only show up when modules actually collaborate, so keep a small set of integration tests that start the real application with a real database:

  • Event flow end to end. Trigger an action in one module and assert that the subscribing module's state changed. This catches missing registrations and mismatched event types.
  • Timing relative to commit. Force the publishing transaction to roll back and assert that no after-commit handler ran. Then let it commit and assert that the handler saw committed data.
  • Outbox dispatch. Write an event to the outbox, run the dispatcher, and assert that the row is marked as sent. Kill the dispatcher mid-batch and confirm the remaining rows are retried.
  • Idempotency. Deliver the same event twice and assert that the side effect happened once.

These tests are slow, so keep them few and aim each one at a failure mode that fakes cannot reach.

Alternatives, development cost and engineering time

None of these structures is free. They differ in when you pay and how much.

Approach Upfront cost Cost of a cross-cutting change Path to extraction
Unstructured layered monolith Lowest High: any class can depend on any other Redesign
Shared-database integration Low High: schema changes break other modules' queries Redesign plus data migration
Event-first in-process Medium Medium: coupling hides in event payloads and ordering Transport change, but flows are hard to trace
Module APIs plus events Medium Low: only the public interface is shared Transport change
Separate services Highest Low, but every contract change needs coordinated deployment Already extracted

The upfront work in the module approach goes into DTOs, mapping code at every boundary, architecture tests that block forbidden imports, and an outbox if events must survive a crash. The mapping feels like busywork. It is what stops a renamed entity field from turning into an edit across five modules.

The payback comes from fewer accidental ripple changes and faster tests, because a module can be tested against fakes of its neighbours' interfaces. Ownership is also clearer: one team owns each interface and its data. If you later extract a module, swapping the in-process call for a network call is a transport change, not a redesign. It is still real work. The caller must now handle timeouts, retries, latency and a dependency that can be partly down. None of those failure modes existed in-process.

For a small team or early-stage code where module boundaries are still moving, the full approach is overkill. Start with package boundaries and one architecture test. Add DTOs, events and an outbox once the boundaries stop shifting.

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 communication, 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.
  • The dependency graph answers "what does this touch?" in seconds instead of a manual audit.

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.
  • You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
  • 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.