What a bounded context is and why a modular monolith is the place to test it
In domain-driven design, a bounded context is the boundary within which one model and one vocabulary stay consistent. Inside it, "Order" means exactly one thing, with one set of rules and invariants. Outside it, the same word can mean something else, and that is acceptable. Sales may treat an order as a negotiable quote, while Fulfillment treats it as a fixed list of items to pick and ship. A bounded context is not a folder, a package, or a set of tables. Those are ways to implement it. The boundary is semantic, and code structure either respects it or erodes it.
Teams draw these boundaries for two reasons. First, they contain model drift. When a single shared Customer class has to serve billing, support, and marketing, it collects fields and conditionals until no one can change it safely. Second, a boundary gives a team clear ownership: one group decides what the model means and how it changes.
Microservices are often presented as the way to get these boundaries, but that is an expensive way to discover you drew them wrong. A modular monolith lets you test boundaries inside one deployable. If you split a context badly, moving code between modules is a refactor. Across services, the same mistake means fixing network calls, serialization formats, versioned contracts, and partial failures.
This approach suits backend and platform teams working with several sub-domains, especially when more than one team commits to the same codebase and boundaries are already blurring.
Discovering contexts from language and business capability
Start with the language, not the schema. Sit in on conversations between sales, warehouse and finance staff and note where the same word carries different rules. When people argue about what a term "really" means, they are usually describing two contexts that share a word.
| Term | Sales | Fulfilment | Billing |
|---|---|---|---|
| Order | A quote the customer accepted; price and discounts matter | A set of items to pick, pack and ship; weight and location matter | A source of invoice lines; tax and payment terms matter |
| Customer | A prospect with a pipeline stage | A delivery address and contact | A legal entity with credit limits and a billing address |
Each column has its own invariants and its own reasons to change. One shared Order class that serves all three accumulates nullable fields and conditional logic, and every team's change risks breaking the others.
Next, group by business capability: pricing, inventory allocation, invoicing, dispute handling. Capabilities stay stable while org charts and code change. Tables and existing folders are poor starting points because they reflect past implementation choices. A single customers table often serves three contexts, and that shared table is the problem you are trying to remove.
Resist drawing boundaries early or too finely. If you split before the language settles, you get modules that call each other on every request and translation layers that map near-identical models. Those cost more than the coupling they were meant to prevent. Start coarse, and split a context only when its vocabulary has clearly diverged.
How it works: modules, public APIs and owned data
Each bounded context becomes one module, and each module has two parts: a small public surface and everything else. The public surface can be a facade, an application service interface, or a set of published commands and queries. Callers depend only on that surface. Entities, repositories, domain services and persistence mappings stay internal. Use the language's visibility rules where they exist, such as package-private classes, internal modifiers or unexported identifiers. Otherwise, rely on a package convention that tooling can check. A narrow surface lets a context change its internal model without coordinating with every caller. It is also the seam you would later turn into a network API if the context is ever extracted.
+------------------------- one process -------------------------+
| +---- Ordering -----+ +---- Billing ------+ |
| | OrderingApi (pub) |---->| BillingApi (pub) | |
| |-------------------| |-------------------| |
| | domain, repos, | | domain, repos, | |
| | handlers (private)| | handlers (private)| |
| +--------|----------+ +--------|----------+ |
+-----------|-------------------------|---------------------------+
[ ordering schema ] [ billing schema ]
Data ownership follows the same line. Each context owns one schema or a clearly named table set. Other contexts never read those tables directly. There are no joins and no foreign keys across contexts. A cross-context foreign key creates a hidden dependency. It ties migration order, delete semantics and locking behaviour together, and the module APIs never show it. Store the other context's identifier as a plain value instead.
Reporting usually needs joined data. Serve it from read models that each context publishes, or from a dedicated reporting context that builds its own denormalised tables from those publications.
// --- billing/BillingApi.java: the only types other modules may import ---
package com.example.billing;
public interface BillingApi {
InvoiceId issueInvoice(IssueInvoice command);
}
// --- billing/IssueInvoice.java and billing/InvoiceId.java: published command and result ---
public record IssueInvoice(String orderId, long amountCents, String currency) {}
public record InvoiceId(String value) {}
// --- billing/BillingModule.java: public wiring entry point, returns the interface only ---
public final class BillingModule {
private BillingModule() {}
public static BillingApi create(javax.sql.DataSource dataSource) {
return new BillingService(new InvoiceRepository(dataSource));
}
}
// --- billing/BillingService.java: package-private, so Ordering cannot reference it ---
final class BillingService implements BillingApi {
private final InvoiceRepository invoices;
BillingService(InvoiceRepository invoices) { this.invoices = invoices; }
@Override
public InvoiceId issueInvoice(IssueInvoice cmd) {
// Domain rules live here and can change without touching callers.
return invoices.insert(cmd.orderId(), cmd.amountCents(), cmd.currency());
}
}
// --- billing/InvoiceRepository.java: package-private, writes only to the billing schema ---
final class InvoiceRepository {
private final javax.sql.DataSource dataSource;
InvoiceRepository(javax.sql.DataSource dataSource) { this.dataSource = dataSource; }
InvoiceId insert(String orderId, long amountCents, String currency) {
// INSERT INTO billing.invoice (id, order_id, amount_cents, currency) ...
// order_id is a plain value: no foreign key into the ordering schema.
throw new UnsupportedOperationException("JDBC code omitted");
}
}
A Billing module in Java whose callers see only the BillingApi interface, its command and result records, and a factory, while the service and repository stay package-private and store the order ID as a plain value with no cross-schema foreign key.
Implementing it: enforcing boundaries mechanically in the build and CI
Boundaries that exist only as folder names and code-review etiquette erode under deadline pressure. When a deadline lands, importing billing.internal.InvoiceRepository from the orders module is a one-line fix, and the reviewer approves it because the alternative is a new public API and a slipped release. Each exception makes the next one look normal. Enforcement has to be mechanical so that the shortcut fails the build instead of passing review.
Make illegal imports fail to compile
The strongest option is to give each context its own build module or project. Expose only its public API package and declare dependencies explicitly. If orders does not depend on billing's internals, the compiler rejects the import.
When splitting the build is not yet practical, run architecture tests or static-analysis dependency rules in CI. A typical rule reads: classes in ..orders.. may depend only on ..billing.api.., never on ..billing.internal.., and no module may reference another module's persistence types.
Pair this with a data check. Give each context its own database schema and its own credentials, so a cross-schema join fails at runtime and in integration tests.
Restructure incrementally
Start by running the rules in report-only mode and recording the existing violations as a baseline. Then fail CI on any new violation, so the debt stops growing on day one.
Next, burn the baseline down one context at a time. Extract its public API, move its tables into its own schema, and delete its entries from the allowlist. Finish each context before starting the next. A context left half-migrated, with some callers on the new API and some on the old internals, is how restructuring efforts stall between two architectures.
package com.example.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.noClasses;
import static com.tngtech.archunit.library.freeze.FreezingArchRule.freeze;
// Runs with the normal test suite, so a boundary violation fails CI like any other test.
@AnalyzeClasses(packages = "com.example", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// Strict rule: orders may reach billing only through billing.api.
@ArchTest
static final ArchRule ordersUsesOnlyBillingApi =
noClasses().that().resideInAPackage("..orders..")
.should().dependOnClassesThat().resideInAPackage("..billing.internal..");
// Baseline rule for the migration: freeze() records today's violations in a
// violation store and fails only on NEW ones. Commit the store, and delete
// entries as each context is cleaned up.
// First run needs freeze.store.default.allowStoreCreation=true in archunit.properties.
@ArchTest
static final ArchRule noForeignPersistenceTypes = freeze(
noClasses().that().resideInAPackage("..orders..")
.should().dependOnClassesThat().resideInAPackage("..billing.persistence.."));
}
An ArchUnit test in Java that fails the build when orders imports billing internals, and uses a frozen baseline so existing persistence violations are tolerated while any new one breaks CI.
Context mapping and anti-corruption layers inside one process
Once modules have real boundaries, the relationships between them need names. A context map records who depends on whom and who adapts to whom. In a monolith, each relationship should be visible in the module graph, not only in a diagram.
| Relationship | When it fits | How it shows up in code |
|---|---|---|
| Customer/supplier | Downstream needs influence the upstream's roadmap | One-way dependency; supplier owns its API and consumer-driven tests |
| Conformist | Upstream is stable and its model is good enough | Downstream imports the upstream's public types directly |
| Shared kernel | Two teams co-own a small, slow-changing model | A separate shared package with joint review |
| Published language | Several consumers need one stable format | A contract module of DTOs and events, versioned on its own |
A shared kernel tends to become a dumping ground. "Common" types gather there because it is the one package everyone can import, and every change then needs sign-off from every owner. Keep it small enough to read in one sitting, or don't create it.
Conformist is often the honest choice. If the upstream model is stable and adequate, translating it buys nothing except mapping code to maintain.
When the upstream model is legacy or would distort your language, put an anti-corruption layer in the downstream module, at its edge. It should be the only code that imports the upstream API. Keep it thin: map fields, normalise identifiers, translate error states, and put no business rules in it. Test the translator directly with fixtures of upstream payloads, so that a change upstream breaks one focused test rather than a dozen domain tests.
// billing/src/acl/legacy-customer.ts: the only file in billing that imports the legacy CRM module
import type { LegacyCustomerRecord } from '@app/legacy-crm';
import type { Customer } from '../domain/customer';
export type TranslationResult =
| { ok: true; customer: Customer }
| { ok: false; reason: 'archived' | 'missing-email' };
// Thin mapping only: rename fields, normalise ids, translate status codes. No billing rules here.
export function toCustomer(rec: LegacyCustomerRecord): TranslationResult {
if (rec.STATUS_CD === 'X') return { ok: false, reason: 'archived' };
const email = rec.EMAIL_ADDR?.trim();
if (!email) return { ok: false, reason: 'missing-email' };
return {
ok: true,
customer: {
id: `cus_${rec.CUST_NO.trim().padStart(8, '0')}`, // legacy ids are unpadded and space-filled
email: email.toLowerCase(),
name: rec.CUST_NM.trim(),
},
};
}
// billing/src/acl/legacy-customer.test.ts: fixtures mirror real upstream payload shapes
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toCustomer } from './legacy-customer';
test('maps an active legacy record to a billing Customer', () => {
const fixture = { CUST_NO: ' 4217', CUST_NM: 'Acme Ltd ', EMAIL_ADDR: 'Ops@Acme.test', STATUS_CD: 'A' };
assert.deepEqual(toCustomer(fixture), {
ok: true,
customer: { id: 'cus_00004217', email: 'ops@acme.test', name: 'Acme Ltd' },
});
});
test('turns the legacy archived flag into an explicit reason', () => {
const fixture = { CUST_NO: '9', CUST_NM: 'Old Co', EMAIL_ADDR: 'a@old.test', STATUS_CD: 'X' };
assert.deepEqual(toCustomer(fixture), { ok: false, reason: 'archived' });
});
A thin anti-corruption layer at the edge of the billing module translates legacy CRM records into the billing domain's Customer type, and fixture-based tests exercise it directly so an upstream change breaks only these tests.
Synchronous calls vs in-process events, consistency and the outbox
Once contexts can talk, decide how. A direct call through another module's public API is the simplest option: it runs in the caller's transaction, returns errors immediately, and shows up in a stack trace. It also couples the caller's availability and latency to the callee. An in-process event inverts that dependency. The publisher no longer knows its consumers, but control flow becomes implicit. A breakpoint at the publish site will not tell you which handlers run, in what order, or whether one of them failed silently.
| Concern | Direct call | In-process event | Event via outbox |
|---|---|---|---|
| Consistency | Same transaction | Same or after commit, depending on dispatcher | Eventual, but guaranteed |
| Coupling | Caller knows callee | Publisher unaware of consumers | Publisher unaware of consumers |
| Survives crash | Yes (atomic) | No | Yes |
| Debuggability | Stack trace | Hidden fan-out | Traceable via stored rows |
An in-memory dispatcher gives no delivery guarantee. If the process dies after commit but before handlers run, the event is lost. The outbox pattern closes that gap. You insert the event into an outbox table in the same transaction as the state change, and a relay later reads unpublished rows and dispatches them. This gives at-least-once delivery, so handlers must be idempotent.
Treat event payloads as versioned public APIs. Add fields freely, but never repurpose or remove a field without a new version.
Eventual consistency has a cost. Features that span contexts need read models that lag, plus UI and support processes that tolerate that staleness.
-- Outbox table lives in the publishing context's schema.
CREATE TABLE ordering.outbox (
id uuid PRIMARY KEY,
event_type text NOT NULL, -- e.g. 'OrderPlaced'
event_version int NOT NULL, -- contract version, part of the public API
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL = not yet relayed
);
-- 1. State change and event are written atomically: both commit or neither does.
BEGIN;
INSERT INTO ordering.orders (id, customer_id, total_cents, status)
VALUES ('5f1c2a9e-7b3d-4c1e-9a0f-2d8e6b4c1a77', 'c-1042', 4999, 'placed');
INSERT INTO ordering.outbox (id, event_type, event_version, payload)
VALUES ('a3e9d7c1-0b2f-4e6a-8c5d-1f7b9e2a4c60', 'OrderPlaced', 1,
'{"orderId": "5f1c2a9e-7b3d-4c1e-9a0f-2d8e6b4c1a77", "totalCents": 4999}');
COMMIT;
-- 2. Relay: claim a batch without blocking other relay workers.
-- Rows stay locked until this transaction ends.
BEGIN;
SELECT id, event_type, event_version, payload
FROM ordering.outbox
WHERE published_at IS NULL
ORDER BY occurred_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- ...dispatch each row to in-process handlers, then mark it done.
-- A crash before this UPDATE means redelivery: at-least-once, not exactly-once.
UPDATE ordering.outbox SET published_at = now()
WHERE id = 'a3e9d7c1-0b2f-4e6a-8c5d-1f7b9e2a4c60';
COMMIT;
-- 3. Consumer side: record processed event ids so a redelivered event is a no-op.
-- Run in the same transaction as the handler's own state change.
INSERT INTO billing.processed_events (event_id)
VALUES ('a3e9d7c1-0b2f-4e6a-8c5d-1f7b9e2a4c60')
ON CONFLICT (event_id) DO NOTHING; -- 0 rows affected => already handled, skip
PostgreSQL sketch of the outbox pattern: the order and its OrderPlaced event commit in one transaction, a relay claims unpublished rows with FOR UPDATE SKIP LOCKED, and the consuming context deduplicates by event id because delivery is at-least-once.
When to use it, when to avoid it, alternatives and the cost picture
Use enforced contexts when several teams change one codebase, the domain has distinct vocabularies, and boundaries are still shifting. Skip them for a small single-team app, a prototype, or a domain nobody understands yet. In those cases you would be turning guesses into CI rules.
| Option | Boundary enforcement | Deploy units | Main risk |
|---|---|---|---|
| Layered monolith | None (layers, not domains) | One | Coupling grows unnoticed |
| Modular monolith | Build and CI rules | One | Shared-runtime failures |
| Microservices from day one | Network | Many | Wrong boundaries are expensive to move |
| Distributed monolith | Nominal only | Many, released together | Network cost without independence |
The shared runtime is the real price. One module's memory leak, a slow query that holds pooled connections, or a failed migration takes down every context. Per-module connection pools, query timeouts and separately run migrations reduce the blast radius but don't remove it.
Illustrative estimate (assumed numbers, not a benchmark).
Net weekly hours = (teams × conflict hours saved) + (teams × debugging hours saved) − upkeep hours
Assume 4 teams, each saving 2 hours of merge-conflict work and 1 hour of debugging per week, against 6 hours of mapping, read-model and CI-rule upkeep:
(4 × 2) + (4 × 1) − 6 = 6 hours per week saved
The figure excludes boundary corrections. Moving logic between modules is a refactor in one repository. Moving it between services is a coordinated migration.
Extract a module when it needs independent scaling, a different release cadence, or a team that owns it end to end. After extraction, every call adds latency and can fail partially. You then need timeouts, retries that are safe because handlers are idempotent, and versioned contracts that consumers can adopt on their own schedule.
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 bounded contexts modular 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.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
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.
- Consumers learn about a breaking change at review time rather than from production errors.
- 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.