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.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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.

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.

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 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:

javascript

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:

javascript

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.


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

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.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

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