Skip to main content

Between modules

How one module uses another: a direct call to its public functions for a question, an event after commit for a side effect. Read it when a feature needs data or behaviour that another module owns.

How it works​

NeedUseExample
Ask a question, need the answer nowCall the other module's public functionbilling.hasEntitlement(userId, 'pro')
Tell others something happenedPublish an event after commitorder.created
Write to another module's tableNever. Call its public function that does the writeusers.markVerified(userId)
Join across two modules' tablesAvoid. Ask each module, or add a read function to the owner

How to build it​

Public functions​

// modules/billing/index.ts
export { hasEntitlement } from './use-cases/has-entitlement';
export { getPlanCatalog } from './use-cases/get-plan-catalog';
export function register(app: FastifyInstance) { /* routes */ }

Only what index.ts exports may be imported by other modules. Everything else is private.

Events after commit​

await withTransaction(async (tx) => {
await ordersRepo.insert(tx, order);
tx.afterCommit(() => events.publish('order.created', { orderId: order.id }));
});

If the transaction rolls back, the event is never sent. When the subscriber must not miss it (money, emails users rely on), write the event to an outbox table in the same transaction and deliver it from a worker.

Dependency direction​

Modules form layers. Shared modules (users, billing, notifications) never import product-feature modules. If two modules need each other, one of them is publishing an event instead, or they should be one module.

Rules​

Rule

No module reads or writes another module's tables, even "just for a join". Our largest API had one table-lookup repository copied into three modules, each reaching into another module's schema. A schema change broke all three.

Rule

Function-local imports to dodge a circular import are a design bug, not a fix. Find the cycle and break it with an event or by moving the code.

Mistakes we made​

  • The sign-in module read the users module's table directly for refresh tokens, so a users-table change broke sign-in.
  • A Python API had 53 function-local imports across modules to work around import cycles, and route files queried other modules' models directly.
  • Primary adapters wrote to the database in two modules, skipping the use-case and its checks.

Checklist​

  • Only index.ts exports are imported across modules.
  • Side effects go through events after commit, or an outbox for critical ones.
  • No cross-module table access.
  • No import cycles; no function-local imports to hide them.