Why one hexagon around the whole monolith is not enough

Hexagonal architecture, also called ports and adapters, puts the domain logic at the center of an application and makes it talk to the outside world only through interfaces it owns. Inbound ports describe what the application can do, such as placing an order or issuing a refund. Outbound ports describe what it needs, such as persistence, payment gateways, or message brokers. Adapters implement those ports for a specific technology. Dependency inversion makes this work: infrastructure depends on the domain's interfaces, and the domain never depends on infrastructure.

A modular monolith is a single deployable unit split into modules that each own a business capability, for example billing, inventory, and shipping.

Many teams apply hexagonal architecture once, around the whole monolith. That keeps SQL and HTTP out of the domain layer, but it does nothing about coupling between domains. Inside that one hexagon, billing can still import inventory's StockItem entity, call its repository, or join against its tables. Each of these shortcuts compiles and passes review, and together they rebuild the tangle the architecture was supposed to prevent.

The per-module approach draws a hexagon around each module. Every module has its own domain core and publishes a small set of inbound ports as its only public API. It reaches other modules through narrow outbound adapters. Other modules cannot see its entities or its tables.

Teams choose this because it gives much of the isolation they expect from microservices without network calls, distributed transactions, or separate deployments. The build checks the boundaries, so a violation fails CI instead of being caught late in review or not at all.

How one module works as its own hexagon

A module is a small hexagon with four parts. At the center is the domain core: entities, value objects and rules, with no imports from the web framework, ORM or message client. Keeping the core free of framework imports lets you test it with plain unit tests and lets it outlive those choices.

Inbound ports are the use cases the module exposes, such as PlaceOrder or GetInvoiceSummary. They are the module's public surface, and nothing else in the module should be reachable from outside. Outbound ports are interfaces the core defines for what it needs, written in its own vocabulary: PaymentAuthorizer, not StripeClient or PaymentsTableRepository. Adapters sit at the edge and connect ports to the outside world: HTTP controllers drive inbound ports, while repositories, message publishers and calls into other modules implement outbound ports.

Dependency inversion between modules follows from this. Suppose Ordering needs credit checks from Billing. Ordering declares an outbound port, CreditCheck, that takes a customer ID and an amount and returns an approved or declined result. An adapter in Ordering's edge implements that port by calling Billing's public inbound port, then translates Billing's response into Ordering's types. Billing's internal entities never cross the boundary, and Ordering depends on a contract it owns rather than on Billing's internals.

A port leaks when its shape mirrors a tool instead of a need. Methods like findByQuery(sql), parameters typed as ORM entities, or return values that carry HTTP status codes tie the core to one implementation. To catch this, ask whether you could write an in-memory fake of the port without importing anything from the real implementation. If you can't, the port's shape still comes from the tool and should be redesigned around what the core needs.

// ordering/core/ports/CreditCheck.ts: defined by Ordering, in Ordering's vocabulary
export type CreditDecision =
  | { kind: "approved" }
  | { kind: "declined"; reason: string };

export interface CreditCheck {
  check(customerId: string, amountCents: number): Promise<CreditDecision>;
}

// billing/public/index.ts: Billing's inbound port, the only thing it exports
export interface AssessCreditLimit {
  assess(req: { accountRef: string; requestedCents: number }):
    Promise<{ withinLimit: boolean; note?: string }>;
}

// ordering/adapters/BillingCreditCheck.ts: lives at Ordering's edge
import type { CreditCheck, CreditDecision } from "../core/ports/CreditCheck";
import type { AssessCreditLimit } from "../../billing/public";

export class BillingCreditCheck implements CreditCheck {
  constructor(private readonly billing: AssessCreditLimit) {}

  async check(customerId: string, amountCents: number): Promise<CreditDecision> {
    // Translate Ordering's request into Billing's contract...
    const res = await this.billing.assess({ accountRef: customerId, requestedCents: amountCents });
    // ...and translate the answer back, so no Billing type reaches Ordering's core
    return res.withinLimit
      ? { kind: "approved" }
      : { kind: "declined", reason: res.note ?? "credit limit exceeded" };
  }
}

// Leak test: an in-memory fake needs no imports from Billing, SQL or HTTP
export class FakeCreditCheck implements CreditCheck {
  constructor(private readonly limitCents: number) {}
  async check(_customerId: string, amountCents: number): Promise<CreditDecision> {
    return amountCents <= this.limitCents
      ? { kind: "approved" }
      : { kind: "declined", reason: "over test limit" };
  }
}

Ordering owns the CreditCheck outbound port, an adapter implements it by calling Billing's public AssessCreditLimit port and translating the result, and an in-memory fake shows the port does not leak.

Synchronous port calls vs in-process domain events

Once a module exposes ports, other modules can reach it in one of two ways. They can call an inbound port directly, as in billing.chargeOrder(orderId). Or the caller can publish a domain event such as OrderPlaced to an in-process dispatcher, and a handler in the billing module reacts to it. These two options differ in what the caller is allowed to know and to wait for.

Concern Synchronous port call In-process domain event
Consistency Immediate; caller sees the result Eventual if handled after commit
Transaction boundary Tempting to share one Each module commits its own
Coupling Caller depends on callee's port Publisher knows only the event type
Failure handling Exception reaches the caller Handler failure needs retry or a dead-letter path
Debuggability One stack trace Control flow is spread across handlers

Use a port call when the caller needs an answer to continue, such as a price quote or an authorization decision. Use an event when the caller only needs to announce a fact.

The difficult case is a change that must update two modules atomically. If orders and billing share one database transaction, the boundary exists only on paper. Billing's schema and locking behaviour now affect order placement, and neither module can be extracted later. The alternative is to commit each module separately and dispatch events after the commit. That breaks the "handler always runs" guarantee if the process dies between the commit and the dispatch. Closing that gap requires an outbox table written in the publisher's transaction, a relay that delivers events from it, and idempotent handlers. It is real machinery, so take it on only where the coupling cost is higher than the cost of building and running it.

import { randomUUID } from "node:crypto";
// Assumed minimal DB abstraction; any driver with transactions fits.
interface Tx { query(sql: string, params: unknown[]): Promise<{ rowCount: number }> }
interface Db { transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T> }

// shared/events.ts: the event type is the only contract both modules share
export interface OrderPlaced { type: "OrderPlaced"; eventId: string; orderId: string; totalCents: number }
type Handler<E> = (event: E) => Promise<void>;

export class InProcessDispatcher {
  private handlers = new Map<string, Handler<any>[]>();
  subscribe<E extends { type: string }>(type: E["type"], h: Handler<E>) {
    this.handlers.set(type, [...(this.handlers.get(type) ?? []), h]);
  }
  async publish(event: { type: string }) {
    for (const h of this.handlers.get(event.type) ?? []) {
      // A failing handler must not undo the publisher's committed work.
      try { await h(event); } catch (err) { console.error("handler failed", event.type, err); }
    }
  }
}

// orders/placeOrder.ts: commit orders' own transaction, then announce the fact
export async function placeOrder(db: Db, bus: InProcessDispatcher, orderId: string, totalCents: number) {
  await db.transaction((tx) => tx.query("INSERT INTO orders.orders (id, total_cents) VALUES ($1, $2)", [orderId, totalCents]));
  // Dispatched after commit: a crash here loses the event. An outbox closes this gap.
  await bus.publish({ type: "OrderPlaced", eventId: randomUUID(), orderId, totalCents } satisfies OrderPlaced);
}

// billing/onOrderPlaced.ts: depends on the event type, not on the orders module
export function registerBillingHandlers(db: Db, bus: InProcessDispatcher) {
  bus.subscribe<OrderPlaced>("OrderPlaced", (e) => db.transaction(async (tx) => {
    // Idempotent: a redelivered event (e.g. from an outbox relay) becomes a no-op.
    const seen = await tx.query("INSERT INTO billing.processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING", [e.eventId]);
    if (seen.rowCount === 0) return;
    await tx.query("INSERT INTO billing.invoices (order_id, amount_cents) VALUES ($1, $2)", [e.orderId, e.totalCents]);
  }));
}

The orders module commits its own transaction and then publishes OrderPlaced to an in-process dispatcher, and billing handles it idempotently in a separate transaction. The comment marks the post-commit gap that an outbox would close.

Data ownership and cross-module queries

Every module owns its tables, ideally in its own schema, and no other module reads or writes them directly. That rules out joins across module boundaries and foreign keys into another module's tables. A foreign key from invoices to orders looks harmless, but it means Billing's migrations depend on Orders' schema. A cross-module join means Orders can no longer rename a column without breaking a report it does not know exists. Referencing other modules by ID only, without database-enforced integrity, keeps each schema private to its owner.

Reporting still needs combined data, and there are three ways to get it. A module can expose a query port that returns its own data in a shape it controls. A read model can be built by subscribing to other modules' events into a denormalised table that the reporting module owns. And a thin composition layer can call several query ports and merge the results in memory.

All three cost something. A report that used to be one SQL join now needs a new port, an event subscription or a projection, so it takes longer to build. Merging in memory can be slower than a join the database would have optimised, and read models are only eventually consistent. Mapping each module's types at every boundary adds code that looks duplicated. Teams often respond with a shared "common models" package. Resist it. That package becomes a single schema that every module depends on, and the coupling you removed from the database comes back through the import graph. The duplication is the price of letting each module change independently.

Enforcing boundaries and testing each module

Make the boundaries mechanical

A boundary that exists only in a design document will not survive the first deadline. There are three main enforcement mechanisms, from weakest to strongest:

  • Language-level visibility. Use package-private or internal types so that only a module's port interfaces can be reached from outside it.
  • Separate build units per module. The build itself rejects any dependency the module has not declared.
  • Architecture tests or import-linting rules in CI. These fail when, for example, the billing core imports anything from shipping other than its published ports.

Each mechanism needs an owner. Rules go stale when modules are renamed, and exceptions accumulate in allowlists. If the team treats a red check as noise to suppress, the boundaries erode as quietly as they would with no checks at all.

Test at three levels

Most tests should exercise the core against in-memory fakes of its outbound ports, such as a map-backed repository, a fixed clock, or a publisher that records the events it receives. These tests run in milliseconds and need no database.

Contract tests run one shared suite against both the fake and the real adapter. This keeps the fake honest: if the real adapter behaves differently, the shared suite catches it.

Keep only a few cross-module integration tests, and reserve them for critical flows.

The example below shows a map-backed fake and an architecture rule written as an ordinary test.

This setup saves time in two ways. Developers get feedback in seconds instead of waiting for a full-stack run. Forbidden imports fail in the author's own pull request, so they never surface weeks later as another team's broken release.

import ast
from pathlib import Path

# Fake for billing's outbound InvoiceRepository port. It is a dict with no database,
# so core tests run in milliseconds.
class InMemoryInvoiceRepository:
    def __init__(self):
        self._rows = {}

    def save(self, invoice):
        self._rows[invoice.id] = invoice

    def get(self, invoice_id):
        return self._rows.get(invoice_id)


# Architecture rule: billing core may reach shipping only through its published ports.
CORE_DIR = Path("src/billing/core")
ALLOWED = "shipping.ports"

def imported_modules(path):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            yield from (alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
            yield node.module  # absolute imports only; relative ones stay in-package

def is_forbidden(name):
    touches_shipping = name == "shipping" or name.startswith("shipping.")
    via_ports = name == ALLOWED or name.startswith(ALLOWED + ".")
    return touches_shipping and not via_ports

def test_billing_core_depends_on_shipping_only_via_ports():
    violations = [
        f"{path}: imports {name}"
        for path in sorted(CORE_DIR.rglob("*.py"))
        for name in imported_modules(path)
        if is_forbidden(name)
    ]
    assert not violations, "Forbidden cross-module imports:\n" + "\n".join(violations)

A dict-backed fake of an outbound repository port, plus an architecture rule written as an ordinary pytest test that parses billing's core modules and fails when any of them imports from shipping other than through shipping.ports.

Migrating a tangled monolith one module at a time

Start with one module, and choose it by domain concept rather than by technical layer. "Invoicing" or "Shipment tracking" makes a good first candidate because the business already treats it as a distinct thing with stable rules. "The repository layer" or "utils" makes a poor one, because a layer cuts across every concept and has no single inbound use case to expose.

Step by step

  1. Draw the inbound port. List every place the rest of the monolith calls into the module today. Group those calls into a small set of use cases, and define them as the module's public interface. Calls that don't fit any use case show you where the boundary is unclear.
  2. Route existing callers through it. Change each caller to use the port instead of reaching into internals. Do this one call site at a time so each change ships on its own and stays easy to revert.
  3. Invert outgoing dependencies one at a time. For each thing the module reaches out to, such as another module's tables, an HTTP client or a mailer, define a port the module owns and move the concrete code into an adapter. Doing them one at a time keeps every intermediate state deployable.
  4. Add the enforcement rule last. Once the module is clean, turn on the forbidden-import check. If you add it earlier, it either fails constantly or gets disabled. If you add it later, the boundary quietly erodes.

Spotting a bad boundary early

A wrong boundary costs more than no boundary, because you pay for the indirection and still get the coupling. Watch for these signs:

  • Chatty ports. One user action needs several round trips across the port.
  • Lockstep changes. Most features touch this module and the same neighbour together.
  • Shared mutable state. The port passes entities that the other side mutates.

If you see these, merge the two modules or redraw the boundary before you migrate the next one.

When to use it, when to avoid it, and the alternatives

This design fits backend and platform teams working on a growing monolith with several distinct domains and many contributors. In that setting, the main cost is change that spreads across modules and tests that need the whole application running. Per-module ports address both problems. Avoid it for small or CRUD-heavy modules. When a module mostly moves rows between a form and a table, ports, adapters and mapping types add files without protecting any real logic. Also hold off while a domain is still unstable. Ports you draw too early lock in boundaries you will probably redraw.

Approach Boundary enforcement Up-front cost Path to extraction
Layered monolith By technical layer only Low Hard; domains interleave
Single application-wide hexagon Edges of the app only Low–medium Hard; core is one blob
Modular monolith, conventions only Code review and discipline Low Moderate; erodes over time
Per-module hexagons Ports plus build or test checks Medium–high Cheaper; adapters already exist
Microservices Network and deployment High Already extracted

The trade-off is front-loaded. You get more indirection and more files from day one. Reporting features also slow down, because they cannot join across module tables freely. In return, changes stay inside one module, tests run against fakes in milliseconds, and extracting a service gets cheaper. Replacing an in-process adapter with a network adapter becomes a localized change. That change does not remove the problems the network introduces: latency, partial failure, retries and data consistency across a boundary that used to share a transaction.

This design also does not give you deployment independence. Every module still shares one runtime, one release cycle and one failure domain. A memory leak in one module takes down all of them.

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 hexagonal architecture monolith, 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.