What this module covers: The microservices narrative convinced a generation of engineers that the only scalable architecture is one where every function is a separate service. The result: systems with 40 services, 40 deployment pipelines, 40 sets of health checks, and inter-service calls that add 5–15ms of latency to every operation. For a blockchain indexer that writes 50,000 events/second to a single PostgreSQL instance, introducing network hops between its own components is architectural self-sabotage. This module covers the Modulith pattern — strict domain boundaries, isolated state modules, and low-latency in-process event communication — and when it outperforms a distributed approach by an order of magnitude.
A blockchain indexer has one primary constraint: write throughput to PostgreSQL. The bottleneck is always the database. Every architectural decision that adds latency between an incoming event and a database write is a decision that hurts throughput.
Consider what happens when you split a monolithic indexer into microservices:
Monolith: TCP socket → parse → validate → write DB Latency: 2–5ms per transaction
Microservices: TCP socket → Ingestion Service → HTTP call → Validation Service → HTTP call → Transform Service → HTTP call → Write Service → write DB Latency: 2ms + 3ms + 3ms + 3ms + 2ms = 13ms per transaction Plus: 4× network hops, 4× serialization/deserialization
The throughput penalty: at 5ms vs 13ms per transaction, the microservice architecture has 2.6× lower throughput ceiling on the same hardware, before considering the operational complexity of 4 deployable services.
The Modulith is not a step backward. It is the architecturally correct choice when:
Your system writes to a single datastore
Your bottleneck is that datastore, not CPU
The subdomains need to coordinate at sub-millisecond latency
Your team can enforce module boundaries through code conventions rather than network boundaries
A Modulith divides the application into domain modules with strict boundaries, but keeps them in the same process. The key discipline: a module may not import from another module's internals. It may only call the other module's public interface.
Think of a Modulith as an apartment building with shared plumbing — one connection pool serving every unit — but every unit has its own locked door (the module boundary). You don't need to build a second building just because one tenant is loud.
This is the key discipline that makes a Modulith work: the network boundary in microservices is replaced by a code review + linting boundary. The cost of violating it is a failed lint check, not a runtime error — much cheaper to enforce.
The gap: no-restricted-imports only sees static import paths. ESLint's rule matches literal specifier strings at parse time. A dynamic import() call with a computed path never appears as a string ESLint can pattern-match, so it sails straight through:
// This silently bypasses no-restricted-imports — the specifier is a runtime // template string, not a static path ESLint's AST rule can evaluate const moduleName = 'aggregator'; const { computeRollup } = await import(`../../analytics/${moduleName}`);
Production incident: a developer needed a one-off analytics computation from the ingestion module and reached for exactly this pattern — await import(\../../analytics/${moduleName}`)— to call intoanalytics/aggregator.tsdirectly. Lint passed, code review didn't catch the string interpolation, and it shipped. Months later the analytics team restructured their internal schema (a change they believed was safe, sinceaggregator.ts` was "private"). The dynamic import broke silently at runtime, and ingestion started throwing on every transaction until the on-call traced it back to the bypassed boundary.
Defense in depth beyond lint-only enforcement: tools like dependency-cruiser analyze the resolved module graph rather than pattern-matching import strings, and can be configured to catch dynamic import() expressions too:
// .dependency-cruiser.js — graph-based boundary enforcement, catches what lint misses module.exports = { forbidden: [ { name: 'no-analytics-internals', comment: 'Only analytics/index.ts is a public entry point', severity: 'error', from: { path: '^src/modules/ingestion' }, to: { path: '^src/modules/analytics/(?!index).+' }, }, ], options: { tsPreCompilationDeps: true, // resolves dynamic import() expressions, not just static ones }, };
Run depcruise as a required CI check (or write a small architecture test that walks the codebase for import( calls touching another module's private files) alongside ESLint — a lint rule alone is a style guideline; a CI gate over the resolved dependency graph is an enforced architectural invariant.
When the ingestion module writes a transaction, the analytics module needs to update its aggregations, the notification module needs to push WebSocket updates, and the ledger module needs to update balances. In microservices, this is three HTTP calls. In a Modulith, it is three in-process event emissions.
// Synchronous in-process emission — no network, no serialization // emit() invokes each listener synchronously, but only up to its first // `await` — it does NOT wait for the returned promise to settle domainEvents.emit(EVENTS.TRANSACTION_INGESTED, transaction);
return transaction; }
// modules/analytics/index.ts import { domainEvents, EVENTS } from '../../shared/events';
What emit() actually does — and doesn't do:EventEmitter.emit() calls every registered listener synchronously, in registration order, before emit() itself returns. If a listener is async, emit() still only runs it synchronously up to its first await — the instant the listener suspends, emit() moves on (or returns) without ever waiting for the returned promise to resolve. So ingestTransaction above is never blocked on analytics or notifications finishing their database writes — a common claim about EventEmitter (that async listeners make emission "block") is simply wrong.
The real problems with plain EventEmitter for this use case are different, and easy to miss:
Unhandled rejections nobody catches. Because emit() never awaits what a listener returns, if that async listener throws after its first await, the rejection has no .catch() anywhere — it surfaces as an unhandledRejection with no obvious link back to the transaction that triggered it.
No backpressure or completion tracking.EventEmitter has no API to ask "have all listeners for this event finished?" and no way to slow ingestion down if a listener falls behind. Every emission is fire-and-forget, by construction, not by choice.
These — not blocking — are the actual reasons to reach for emittery or a more structured event bus below.
The void on domainEvents.emit(...) is intentional — the ingestion module does not care when listeners complete, only that it fires the event. Ingestion latency is decoupled from analytics and notification latency.
If analytics is slow (processing 5,000 events/sec) and ingestion is fast (emitting 50,000 events/sec), the analytics listener builds up a queue:
// Add bounded queue with backpressure to async event listener const analyticsQueue = new BoundedQueue(10_000); // max 10K pending
domainEvents.on('transaction:ingested', (transaction) => { if (!analyticsQueue.push(transaction)) { // Queue full — analytics can't keep up // Options: drop, log, or apply backpressure upstream metrics.increment('analytics_queue_overflow'); } });
// Analytics worker drains from the bounded queue async function analyticsWorker() { for await (const transaction of analyticsQueue) { await updateAggregations(transaction); } }
Each domain module should manage its own database interactions. The shared connection pool is an infrastructure concern — the module's repository is the domain concern.
// shared/database.ts — single connection pool, shared infrastructure import pg from 'pg';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 50, // total connections across all modules idleTimeoutMillis: 30_000, });
// modules/ingestion/repository.ts — ingestion's private DB access import { pool } from '../../shared/database';
// modules/analytics/repository.ts — analytics' private DB access import { pool } from '../../shared/database';
export async function updateAggregations(tx: Transaction) { await pool.query( 'INSERT INTO hourly_stats (hour, total_volume, tx_count) VALUES (...) ON CONFLICT DO UPDATE ...', [...] ); }
Both modules share the pool for efficiency, but their SQL is encapsulated in their own repositories. Neither module can "accidentally" call the other module's queries.
Sometimes a single business operation must update two modules' data atomically. A payment that debits the ledger and writes an ingestion record either both happen or neither does — there is no acceptable partial state where the ledger is debited but the transaction was never recorded, or vice versa.
Firing two independent transactions — one against the ledger repository, one against the ingestion repository — leaves exactly that gap: if the process crashes between them, you have a debited account with no corresponding ingested transaction. Since both modules share the same physical PostgreSQL instance, the fix doesn't require distributed transactions — it requires a shared unit-of-work that both modules' repositories can write through:
// shared/unit-of-work.ts — a transactional context passed across module boundaries import { pool } from './database';
// modules/ledger/index.ts — public interface accepts an externally-managed client export async function debit(client: PoolClient, accountId: string, amount: bigint) { await client.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, accountId] ); }
// modules/ingestion/index.ts — same shape: accepts the shared client export async function writeWithClient(client: PoolClient, tx: Transaction) { await client.query( 'INSERT INTO transactions (hash, block_height, sender, amount) VALUES ($1, $2, $3, $4)', [tx.hash, tx.blockHeight, tx.sender, tx.amount] ); }
// app.ts (composition root) — the only place allowed to coordinate two modules' writes import { withTransaction } from './shared/unit-of-work'; import * as ledger from './modules/ledger'; import * as ingestion from './modules/ingestion';
export async function processDebitedIngestion(tx: RawTransaction) { return withTransaction(async (client) => { await ledger.debit(client, tx.senderAccountId, tx.amount); await ingestion.writeWithClient(client, tx); // Both writes commit together, or neither does — no partial state }); }
Each module still owns its own SQL — the boundary isn't broken, only the transaction is shared, and only the composition root (never another module) is allowed to coordinate across two modules' public interfaces this way.
This only works because both modules live in the same database and process. Once a module is extracted to its own datastore or service (Module 9), atomic two-writes-in-one-transaction is no longer available — that's when you move to a saga: one module's write is the transaction of record, it emits a domain event, and the second module's write happens in an event handler with an explicit compensating action if it fails. Module 11 covers this exact pattern for the Payment aggregate — a single transaction should only ever mutate one aggregate directly, with any second aggregate updated via a domain event.
A single incoming transaction flows through several modules — ingestion validates and writes it, analytics aggregates it off an event, notifications push it to subscribers. Threading a requestId or trace ID through every function signature so your logs correlate across modules is tedious, and it's the kind of thing that silently rots the first time someone forgets to pass it along. Node's built-in AsyncLocalStorage propagates that context implicitly through the async call chain, without touching every function signature.
// shared/context.ts import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getRequestId(): string | undefined { return requestContext.getStore()?.requestId; }
// modules/ingestion/index.ts — enter the context once, at the edge import { requestContext } from '../../shared/context'; import { randomUUID } from 'node:crypto';
The one gap:AsyncLocalStorage propagates through the continuation chain of promises, timers, and EventEmitter callbacks that Node's async_hooks track — but if a listener hands work off to a separate structure like the BoundedAsyncQueue below, the context does not automatically travel with the queued item. Capture it explicitly at push time (const store = requestContext.getStore(); queue.push({ item, store })) and re-enter it with requestContext.run(store, () => ...) when the worker dequeues it.
In a Modulith, the biggest risk is an async event listener that cannot keep up with the emitter, causing the intermediate queue to grow without bound. This is the in-process equivalent of the external OOM from Module 4.
// Monitoring module queue depths in production const queueDepths = { analytics: 0, notifications: 0, ledger: 0, };
// Register high-water-mark gauges setInterval(() => { for (const [module, depth] of Object.entries(queueDepths)) { queueDepthGauge.labels(module).set(depth); if (depth > 50_000) { logger.warn(`${module} queue is backing up: ${depth} events pending`); } } }, 5_000);
The app.ts file is the only place where modules are wired together. It knows about all modules; modules know nothing about each other except through the event bus.
// app.ts — composition root import Fastify from 'fastify'; import { pool } from './shared/database'; import { domainEvents } from './shared/events';
// Import each module's public interface import { createIngestionModule } from './modules/ingestion'; import { createAnalyticsModule } from './modules/analytics'; import { createNotificationModule } from './modules/notifications'; import { createLedgerModule } from './modules/ledger';
When a single module genuinely needs to scale independently (the analytics module needs 10× the compute of ingestion), the modulith's clean boundaries make extraction straightforward:
Step 1: The analytics module already has: - A clean public interface (index.ts) - Its own repository (no shared SQL with other modules) - Its own event listener (no direct function calls from ingestion)
Step 2: Wrap the analytics module in a separate Node.js process - Create analytics-service/ with its own app.ts - Replace in-process event listener with a Kafka consumer - Replace domain event emission in ingestion with Kafka producer
Step 4: Deploy analytics-service as a separate pod
The domain boundary was already clean — extraction is mechanical, not architectural. This is the Modulith's long-term value: it lets you defer the operational complexity of microservices until the performance need actually justifies it.
Context: A blockchain indexer Modulith processing 8,000 transactions/second. The notification module sent WebSocket updates to 12,000 connected subscribers.
What happened:
At peak load during a token launch, WebSocket subscriber count jumped to 180,000. The notification module's event listener became the slowest module — sending to 180,000 subscribers took ~200ms per transaction. Ingestion was emitting 8,000 events/sec; notifications could only process 5 events/sec.
// The broken pattern domainEvents.on('transaction:ingested', async (transaction) => { // Sending to 180,000 subscribers takes 200ms await Promise.all( subscribers.map(sub => sub.send(JSON.stringify(transaction))) ); // 8,000 events queued per second, 5 processed per second // After 1 minute: 480,000 events in memory = ~960MB // After 3 minutes: OOM });
The fix — bounded queue with subscriber batching:
// Step 1: bound the notification queue const notifQueue = new BoundedAsyncQueue(5_000);
domainEvents.on('transaction:ingested', (transaction) => { if (!notifQueue.push(transaction)) { // Queue full — skip notification for this transaction // Subscribers get the next one; no data loss for the indexer metrics.increment('notification_skipped'); } });
// Step 2: batch subscribers into groups of 1,000 async function notificationWorker() { for await (const transaction of notifQueue) { const BATCH_SIZE = 1_000; const payload = JSON.stringify(transaction);
// Send in parallel batches to avoid blocking for 200ms for (let i = 0; i < subscribers.size; i += BATCH_SIZE) { const batch = Array.from(subscribers).slice(i, i + BATCH_SIZE); await Promise.all(batch.map(sub => sub.send(payload).catch(() => {}))); } } }
After the fix: queue depth stabilized at 200–800. Memory: constant 150MB. 12,000 skipped notifications over 10 minutes of peak load — acceptable for a "best-effort" notification system.
Single-database systems with < 50ms inter-domain latency requirements have no valid reason for microservices.
Module boundaries
Enforce via ESLint no-restricted-imports. Modules export only a public index.ts.
In-process event bus
EventEmitter for sync, emittery for async. Zero serialization, zero network latency.
Async emission
void events.emit(...) decouples ingestion latency from listener latency.
Bounded queues
Every async listener needs a HWM. Unbounded listeners OOM under load spikes.
Shared DB pool
One pool per process, not per module. Each module has its own repository (private SQL).
Composition root
Only app.ts knows about all modules. Modules know about shared infrastructure only.
Deployment
1 Dockerfile, 1 Kubernetes deployment. 8 pods for the whole system.
Migration path
Clean module boundaries make extraction mechanical when performance genuinely requires it.
The Modulith works until a single module needs to scale 10× independently, or until you need geographic distribution. Module 9 covers the precise moment to split — how to identify the correct seam, execute the extraction without data loss, and manage the operational split-plane.
In a Modulith architecture, what is the most appropriate way for the ingestion module to notify the analytics module about a new transaction without creating tight coupling?
What is the purpose of using a Bounded Async Queue for event listeners within a Modulith?
Which of the following is a strict rule when organizing database access in a well-structured Modulith?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.