Module A-9·23 min read

Eliminating internal network hops with strict in-memory domain boundaries, isolated state modules, and low-latency internal event emitters.

Module 8 — The Modern Hybrid Monolith: High-Throughput Modulith Architecture

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.


The Distributed Systems Fallacy for Single-Database Systems

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:

text

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:

  1. Your system writes to a single datastore
  2. Your bottleneck is that datastore, not CPU
  3. The subdomains need to coordinate at sub-millisecond latency
  4. Your team can enforce module boundaries through code conventions rather than network boundaries

Domain Module Structure

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.

text

Enforcing boundaries with ESLint:

javascript

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 In-Process Event Bus: Zero-Latency Cross-Domain Communication

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.

Option 1: Node.js EventEmitter (Synchronous)

javascript
javascript
javascript

The synchronous emission problem: EventEmitter.emit() runs all listeners synchronously before returning. If analytics and notifications are async (they issue database writes), your ingestion function waits for all of them. This couples ingestion latency to analytics latency.

Option 2: emittery for Async Event Handling

javascript

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.

Handling Backpressure on the Event Bus

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:

javascript

Module Isolation: Own Your Database Connection Pool

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.

javascript
javascript
javascript

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.


Memory Boundary Management: Preventing Unbounded Queues

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.

javascript
javascript

Startup Wiring: The Composition Root

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.

javascript

Deploying a Modulith: One Binary, One Pod

The deployment simplicity is the Modulith's most underrated advantage:

dockerfile
yaml

Compare to 8 pods per service × 5 services = 40 pods to manage, monitor, and scale. The modulith runs the same workload in 8 pods.


The Migration Path: Modulith → Microservice

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:

text

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.


Production Incident: Unbounded Event Queue OOM

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.

javascript

The fix — bounded queue with subscriber batching:

javascript

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.


Summary

ConceptKey Takeaway
Modulith rationaleSingle-database systems with < 50ms inter-domain latency requirements have no valid reason for microservices.
Module boundariesEnforce via ESLint no-restricted-imports. Modules export only a public index.ts.
In-process event busEventEmitter for sync, emittery for async. Zero serialization, zero network latency.
Async emissionvoid events.emit(...) decouples ingestion latency from listener latency.
Bounded queuesEvery async listener needs a HWM. Unbounded listeners OOM under load spikes.
Shared DB poolOne pool per process, not per module. Each module has its own repository (private SQL).
Composition rootOnly app.ts knows about all modules. Modules know about shared infrastructure only.
Deployment1 Dockerfile, 1 Kubernetes deployment. 8 pods for the whole system.
Migration pathClean 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.

Next: Module 9 — Pragmatic Microservice Deconstruction: Splitting Ingestion from Analytics →


Knowledge Check

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.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.