What direct module calls and in-process events are
This applies to backend teams that ship a single deployable but split it into modules with clear ownership: billing, inventory, orders. Inside one process, modules can talk in two ways.
Direct module calls
A direct call goes through a module API: an interface or facade the callee module publishes and owns. Everything behind it stays internal, including repositories, entities and helper services. The caller invokes a method, waits for the result and handles failures inline. The caller depends on the callee's API, so the callee can rewrite its internals freely as long as that contract holds.
In-process events
An in-process event is dispatched inside the same process to zero or more handlers that the publisher never references. The dependency points the other way: subscribers depend on the publisher's event contract, and the publisher does not know who is listening. That is the decoupling events offer.
Because subscribers bind to the payload, treat domain events as a public contract. Renaming a field or changing its meaning breaks consumers just as an API change would. Version events, add fields instead of mutating them, and deprecate deliberately.
Thin versus fat events
Payload size is the main design tension.
- Thin events (just
OrderPlacedwith an ID) force handlers to call back into the publisher for details. That reintroduces the coupling the event was meant to remove. - Fat events that carry the publisher's internal model leak its schema to every subscriber.
A good event carries the facts consumers need, expressed in contract types rather than internal entities.
// billing/api.ts: the only billing surface other modules may import
export interface BillingApi {
authorize(orderId: string, amountCents: number): Promise<{ approved: boolean }>;
}
// orders/events.ts: a public contract, versioned like an API
export interface OrderPlacedV1 {
type: "orders.OrderPlaced";
version: 1;
orderId: string;
lines: { sku: string; quantity: number }[]; // contract type, not the Order entity
totalCents: number; // add new fields later; never rename or repurpose these
}
// shared/bus.ts: minimal in-process dispatcher
type Handler = (event: OrderPlacedV1) => Promise<void>;
export class EventBus {
private handlers: Handler[] = [];
subscribe(h: Handler): void { this.handlers.push(h); }
async publish(e: OrderPlacedV1): Promise<void> {
for (const h of this.handlers) await h(e); // zero or more handlers, unknown to publisher
}
}
// orders/service.ts: orders depends on BillingApi, but not on any subscriber
export async function placeOrder(billing: BillingApi, bus: EventBus, order: OrderPlacedV1) {
// Direct call: wait for the result and handle failure inline
const { approved } = await billing.authorize(order.orderId, order.totalCents);
if (!approved) throw new Error(`payment declined for ${order.orderId}`);
// Event: carries the facts consumers need, so they never call back into orders
await bus.publish(order);
}
// inventory/handlers.ts: inventory depends on the event contract, not on orders internals
export function registerInventory(bus: EventBus, reserve: (sku: string, qty: number) => Promise<void>) {
bus.subscribe(async (e) => {
for (const line of e.lines) await reserve(line.sku, line.quantity);
});
}
Orders calls billing through a published facade and waits for the result, then publishes a versioned OrderPlacedV1 event. Inventory handles that event using only the contract's fields, so it never imports or calls into the orders module.
A decision rule: direct calls by default, events for true side effects
Use a direct call through the target module's public API whenever the caller needs an answer. Queries ("what is this customer's credit limit?") and commands whose outcome decides the next step ("reserve stock, then charge") belong here. The dependency appears in the code, the compiler checks it, and a failure comes back to the caller as a return value or exception it must handle.
Reserve domain events for facts that have already happened and whose reactions the publisher does not need. Examples include sending notifications, updating projections, writing audit entries, invalidating caches, and starting follow-on processes. A good test: if every subscriber were deleted, would the publishing module still be correct? If yes, an event fits.
Avoid events when the publisher's correctness depends on a handler running. If an order is only valid once inventory is decremented, publishing OrderPlaced and hoping a handler does the decrement does not remove the coupling. It hides the coupling from the code, the type checker and the next reader.
Avoid direct calls when they create cycles, such as Billing calling Orders while Orders calls Billing. Also avoid them when a module's API grows into dozens of fine-grained methods that other modules chain together. Both drift toward a big ball of mud.
| Aspect | Direct module call | In-process event |
|---|---|---|
| Dependency direction | Caller depends on callee's API | Subscriber depends on publisher's event contract |
| Failure visibility | Immediate, in the caller | Depends on dispatch; can be swallowed or deferred |
| Consistency | Same transaction is easy | Same or separate transaction, by design choice |
| Debuggability | Linear stack trace | Requires tracing across handlers |
| Extraction readiness | Becomes a synchronous remote call | Maps naturally to a message broker |
Worked scenario: placing an order
Consider a checkout that touches four modules: Ordering, Inventory, Notifications and Analytics. Each owns its data and exposes a narrow public surface.
- Ordering calls
Inventory.apidirectly and publishesOrderPlaced - Inventory exposes
ReserveStock(orderId, lines)and returns a result - Notifications subscribes to
OrderPlaced - Analytics subscribes to
OrderPlaced
Inventory reservation: direct call
Ordering cannot finish placing an order without knowing whether stock was reserved. If reservation fails, the order must be rejected or held, and the customer needs an answer in the same request. The caller depends on the result, so a direct call through Inventory's module API is the honest representation. It makes the dependency explicit, returns a typed result Ordering can branch on, and lets both writes share one transaction if you choose. An event here would hide a hard dependency behind indirection, and Ordering would still have to wait for a reply.
Notification and analytics: events
Once the order is committed, Ordering's job is done. Whether a confirmation email goes out, or a dashboard counter increments, does not change the order's outcome. Ordering should not need to know these modules exist. Publishing an OrderPlaced domain event lets each subscriber react independently. A new consumer, such as a loyalty-points module, can subscribe later without touching Ordering's code.
The code example that follows shows the order handler calling ReserveStock, checking the result, persisting the order, and publishing OrderPlaced.
// ordering/place-order.ts: Ordering depends only on Inventory's public API module
import type { InventoryApi, OrderLine } from "../inventory/api";
import type { EventBus } from "../shared/event-bus";
import type { OrderRepository } from "./order-repository";
export interface OrderPlaced {
type: "OrderPlaced";
orderId: string;
customerId: string;
totalCents: number;
}
export type PlaceOrderResult =
| { status: "placed"; orderId: string }
| { status: "rejected"; reason: string };
export class PlaceOrderHandler {
constructor(
private readonly inventory: InventoryApi,
private readonly orders: OrderRepository,
private readonly events: EventBus,
) {}
async handle(orderId: string, customerId: string, lines: OrderLine[]): Promise<PlaceOrderResult> {
// Direct call: the order's outcome depends on this answer, so we wait and branch on it.
const reservation = await this.inventory.reserveStock(orderId, lines);
if (!reservation.ok) {
return { status: "rejected", reason: reservation.reason };
}
const totalCents = lines.reduce((sum, l) => sum + l.unitPriceCents * l.quantity, 0);
await this.orders.save({ orderId, customerId, lines, totalCents, status: "placed" });
// Event: published only after the order is persisted. Ordering does not know or care
// whether Notifications, Analytics, or a future module reacts to it.
await this.events.publish<OrderPlaced>({ type: "OrderPlaced", orderId, customerId, totalCents });
return { status: "placed", orderId };
}
}
The Ordering handler calls Inventory's reserveStock API directly and branches on the typed result, then persists the order and publishes an OrderPlaced event for any subscribers such as Notifications and Analytics.
How transactions, errors and ordering work with in-process events
If handlers run synchronously inside the publisher's transaction, a failing loyalty-points handler rolls back the order. You get atomicity, but you also get the coupling of a direct call without the visibility: checkout latency now includes every subscriber's work, and any subscriber can fail checkout.
Dispatching after commit fixes the failure coupling but opens a gap. If the process crashes between commit and dispatch, the side effect is silently lost.
The transactional outbox closes that gap without a broker. Ordering writes the event to an outbox table in the same transaction as the order. A background worker then reads unprocessed rows, invokes the handlers and marks the rows done. This has three consequences:
- Eventual consistency. Subscribers see the change after a delay.
- Duplicate delivery. A crash after dispatch but before marking the row causes a redelivery.
- Idempotent handlers. Because of redelivery, every handler must tolerate running twice. The usual approach is to record the event ID in a processed-events table with a unique constraint, inside the handler's own transaction.
Track retries per handler, not per event, so a failing email handler doesn't re-run points. Use backoff, and park rows in a failed state after a fixed number of attempts. Don't rely on registration order. If one handler needs another's result, use a direct call.
| Concern | Direct call | In-transaction handlers | Outbox |
|---|---|---|---|
| Failure propagation | Exception reaches caller | Rolls back publisher | Isolated per handler |
| Lost side effects | No | No | No |
| Duplicates | No | No | Yes, so handlers must be idempotent |
| Debuggability | Readable stack trace | Stack spans the dispatcher | Separate worker trace |
-- PostgreSQL. Outbox rows and dedup records live in the same database as the modules.
CREATE TABLE outbox (
event_id uuid PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Retry state is tracked per handler, so a failing email handler doesn't re-run points.
CREATE TABLE outbox_deliveries (
event_id uuid NOT NULL REFERENCES outbox(event_id),
handler text NOT NULL,
attempts int NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'pending', -- pending | done | failed
PRIMARY KEY (event_id, handler)
);
CREATE TABLE processed_events (
handler text NOT NULL,
event_id uuid NOT NULL,
PRIMARY KEY (handler, event_id) -- the unique constraint that makes redelivery harmless
);
-- Ordering module: the order and its event commit or roll back together.
BEGIN;
INSERT INTO orders (id, customer_id, total_cents)
VALUES ('6f1c2a9e-0000-4000-8000-000000000001', 42, 4999);
INSERT INTO outbox (event_id, event_type, payload)
VALUES ('a3d4e5f6-0000-4000-8000-000000000002', 'OrderPlaced',
'{"order_id": "6f1c2a9e-0000-4000-8000-000000000001", "customer_id": 42}');
COMMIT;
-- Loyalty handler, run by the worker in its own transaction.
-- If the event was already processed, the INSERT claims nothing and the UPDATE touches no rows.
BEGIN;
WITH claimed AS (
INSERT INTO processed_events (handler, event_id)
VALUES ('loyalty.award_points', 'a3d4e5f6-0000-4000-8000-000000000002')
ON CONFLICT DO NOTHING
RETURNING event_id
)
UPDATE loyalty_accounts SET points = points + 10
FROM claimed WHERE loyalty_accounts.customer_id = 42;
COMMIT;
The order and its OrderPlaced event are written atomically to an outbox table. The loyalty handler records the event ID under a unique constraint in the same transaction as its side effect, so a redelivered event awards points only once.
Enforcing module boundaries regardless of mechanism
Neither direct calls nor events protect a boundary on their own. A handler that reaches into another module's repository couples the two modules as tightly as a direct call into its internals. The boundary has to be enforced separately, through three rules.
One public surface per module
Each module exposes a single entry point: its module API (commands and queries), plus the event types it publishes. Everything else is internal. Callers then depend on a contract the owning team chose to support, not on whatever classes happened to be reachable.
No shared persistence
A module owns its tables. Other modules do not read them, join against them, or import its data-access types. This rule matters most because a shared table is a hidden API. Any schema change can break another module, and nothing in the module API shows that dependency. If you separate schemas per module, a stray cross-module query fails at runtime instead of slipping through review.
Fail the build on forbidden references
Add an architecture test that runs with the normal test suite. For example, the rule can be: "no type outside the billing namespace may reference anything under billing.internal or billing.persistence; only billing.api and billing.events are importable." The test loads the compiled dependency graph, lists every edge that violates the rule, and fails with the offending source and target. A reviewer can miss a forbidden import, but this test cannot.
When you use both calls and events, write down which interactions use which mechanism and encode that convention in the same tests. Otherwise each engineer picks a mechanism by preference, and the codebase ends up with both patterns and no consistent reason for either.
# Architecture test: fail the build when a module imports another module's internals.
# Runs with the normal test suite (e.g. under pytest). Uses only the standard library.
import ast
import pathlib
SRC = pathlib.Path("src") # layout: src/<module>/...
MODULES = {"billing", "orders", "shipping"}
PUBLIC = {"api", "events"} # the only importable surface per module
def imports_of(path):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield alias.name
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
yield node.module # relative imports stay inside the module
def test_modules_only_use_each_others_public_surface():
violations = []
for path in SRC.rglob("*.py"):
owner = path.relative_to(SRC).parts[0]
for target in imports_of(path):
parts = target.split(".")
if parts[0] not in MODULES or parts[0] == owner:
continue # stdlib, third-party, or own module
# Cross-module edge: only <module>.api or <module>.events is allowed.
# Bare 'billing' is rejected too, so the package root can't leak internals.
if len(parts) < 2 or parts[1] not in PUBLIC:
violations.append(f"{path} -> {target}")
# Report every offending source/target pair, not just the first one.
assert not violations, "Forbidden cross-module references:\n" + "\n".join(violations)
A standard-library Python architecture test that scans each module's imports and fails the build when any code outside a module references something other than that module's api or events package, listing every offending source and target.
Observability and debugging event flows
A direct call is easy to follow. The stack trace shows the caller, "go to definition" lands on the implementation, and a debugger steps straight from the ordering module into the billing module's API. With in-process events, the publisher only references the event type. "Find usages" on OrderPlaced shows where it is raised and possibly where handlers are declared. It does not show the order handlers run in, which ones share the transaction, or what each one triggers next. To understand the full behavior of "place an order," an engineer has to put the chain back together by hand.
Three practices make that reconstruction cheap.
Propagate a correlation ID from dispatch to every handler. The dispatcher should copy the current request's correlation ID into the event context automatically, so handlers never have to remember to do it. Every log line and span produced by a handler can then be joined back to the request that caused it, including handlers that run after the transaction commits.
Log at both ends. Write one structured entry when an event is dispatched (event type, correlation ID, number of subscribers) and one per handler outcome (handler name, duration, success or failure). If you log only failures, you cannot tell whether a handler failed silently or never ran.
Make subscriptions discoverable. Generate a registry of event types and their subscribers from the code, for example through a startup dump or a test that prints the mapping. Commit it so that changes show up in code review.
Without these practices, the cost lands on onboarding. A new engineer learns the event graph by reading every handler, and the senior engineers who already know it become the bottleneck.
Extraction paths, alternatives, and impact on cost and engineering time
Extraction is where the mechanisms diverge most. An after-commit event written to an outbox already behaves like a broker message: it is durable, it is delivered after the producer's transaction, and the consumer runs in its own unit of work. Pointing the relay at a broker instead of an in-process dispatcher changes transport, not semantics. That holds only if handlers already tolerate delay, duplicate delivery, and reordering. A handler that quietly relied on running milliseconds after commit, exactly once, in publish order will break the day the broker sits in between. A chain of synchronous calls is harder to extract. Every call becomes a network hop with new failure modes, and callers that assumed an immediate answer must be redesigned.
Building that tolerance costs real effort: idempotency keys, version checks, and reasoning about states where the read model lags the write. Pay for it only in modules you realistically expect to extract.
| Approach | Development cost | Cognitive overhead | Operational overhead | Extraction readiness |
|---|---|---|---|---|
| Pure direct calls | Low | Low | Low | Poor |
| Synchronous in-transaction events | Low | Medium | Low | Poor |
| After-commit events | Medium | Medium | Low | Fair |
| Outbox-backed events | High | High | Medium | Good |
| External broker | High | High | High | Already extracted |
Most of the engineering time saved comes from the default, not the edge cases. Explicit calls show up in a stack trace and in "go to definition", so debugging and onboarding stay fast. A written decision rule saves time too. Without one, each new feature reopens the same argument, and the codebase drifts into a mix of patterns that nobody chose on purpose.
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.