Published on

Monolith That Nobody Understands — When the Codebase Becomes a Black Box

Authors

Introduction

A monolith isn't the problem. An unstructured monolith is. The difference between a codebase that scales a team and one that paralyzes it isn't the architecture pattern — it's whether code is organized around clear domains, dependencies flow in one direction, and any engineer can find and change any feature without fear.

Symptoms of a Monolith Nobody Understands

Warning signs:
- "Let me ask Sarah — she's the only one who knows the billing code"
- PRs sit in review for days because nobody wants to approve risky changes
- Every bug fix introduces a new bug somewhere else
- New engineer's first PR takes 3 weeks
- Nobody knows what triggers the email at line 4,847 in utils.js
- The test suite takes 45 minutes and is half-broken
- There are 12 files called "helpers.js" in different directories
- The database has 400 tables and no documentation

The Root Cause: Invisible Coupling

// ❌ Typical "nobody understands this" code pattern
// utils/helpers.ts — 4,000 lines, touched by everything
export async function processOrder(orderId: string) {
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId])

  // Billing logic buried in "order processing"
  await stripe.charges.create({ amount: order.total })

  // Email logic buried here too
  await sendgrid.send({ to: order.user_email, template: 'order_confirmed' })

  // Inventory buried here too
  await db.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [order.product_id])

  // Analytics buried here too
  await mixpanel.track('order_completed', { orderId, userId: order.user_id })

  // Slack notification buried here too
  await slack.postMessage({ channel: '#orders', text: `New order: ${orderId}` })
}
// This function knows too much. Changing payment providers, email providers,
// or analytics means touching order processing logic. Everything is tangled.

Fix 1: Domain-Based Module Structure

Before (everything mixed):
src/
├── controllers/
│   ├── orderController.js    ← routes + business logic
│   ├── userController.js
│   └── productController.js
├── utils/
│   └── helpers.js4,000 line dumping ground
├── models/
│   └── *.js                  ← database access mixed with business logic
└── index.js

After (domain modules):
src/
├── modules/
│   ├── orders/
│   │   ├── orders.routes.tsHTTP only
│   │   ├── orders.service.ts      ← business logic
│   │   ├── orders.repository.ts   ← database only
│   │   └── orders.events.ts       ← domain events emitted
│   ├── billing/
│   │   ├── billing.service.ts
│   │   └── billing.repository.ts
│   ├── notifications/
│   │   └── notifications.service.ts
│   └── inventory/
│       └── inventory.service.ts
├── shared/
│   ├── database/
│   ├── events/               ← event bus (decouples modules)
│   └── config/
└── app.ts

Fix 2: Use Domain Events to Decouple

// ✅ Order module raises an event, doesn't know about billing/email/inventory
// modules/orders/orders.service.ts
import { eventBus } from '../../shared/events'

export class OrdersService {
  async processOrder(orderId: string) {
    const order = await this.ordersRepository.findById(orderId)

    await this.ordersRepository.markProcessed(orderId)

    // Emit event — no direct coupling to billing, notifications, etc.
    eventBus.emit('order.processed', { orderId, order })
  }
}

// modules/billing/billing.listener.ts
// Billing subscribes to the event — orders don't know billing exists
eventBus.on('order.processed', async ({ order }) => {
  await billingService.chargeCustomer(order)
})

// modules/notifications/notifications.listener.ts
eventBus.on('order.processed', async ({ order }) => {
  await notificationsService.sendOrderConfirmation(order)
})

// modules/inventory/inventory.listener.ts
eventBus.on('order.processed', async ({ order }) => {
  await inventoryService.decrementStock(order.productId)
})

Now orders, billing, notifications, and inventory are independently changeable. Switching email providers only touches notifications.listener.ts. Nothing else.

Fix 3: Architecture Decision Records

Document why the code is the way it is:

# ADR 001: Use Domain Events for Cross-Module Communication

## Status: Accepted (2026-03-01)

## Context
As the codebase grew, modules became tightly coupled. Changes to order processing
required understanding billing, email, and inventory code.

## Decision
Modules communicate via domain events. The emitter doesn't know about subscribers.

## Consequences
- Pro: Modules are independently changeable and testable
- Pro: New subscribers can be added without touching existing code
- Con: Harder to trace full flow from code alone (must check event subscribers)
- Mitigation: Document all events in events.ts with subscriber list

## Trade-offs considered
Considered direct service injection — rejected because it creates compile-time coupling.

Fix 4: Living Architecture Documentation

// A 30-line diagram beats a 300-page document
// Keep it in the repo, update it with code changes

/*
 * System Overview (updated 2026-03-15)
 *
 * HTTP Request
 *   → routes (validation only)
 *   → service (business logic)
 *   → repository (database only)
 *   → domain event emitted
 *       → billing.listener
 *       → notifications.listener
 *       → inventory.listener
 *
 * Module boundaries: modules NEVER import from each other directly.
 * All cross-module communication goes through shared/events/.
 *
 * Shared code lives in shared/. Business logic NEVER lives in shared/.
 */

Rescue Checklist

  • ✅ Identify the biggest "nobody understands this" files — start there
  • ✅ Move toward domain modules incrementally (strangler fig pattern — don't rewrite)
  • ✅ Add domain events to decouple modules that currently import each other
  • ✅ Write Architecture Decision Records for non-obvious choices
  • ✅ Treat any function > 100 lines as a refactoring candidate
  • ✅ No file called "helpers", "utils", or "common" with > 200 lines
  • ✅ Any new engineer should be able to find any feature in < 5 minutes

Conclusion

A codebase nobody understands is an organizational problem masquerading as a technical one. The solution isn't a big rewrite — it's incremental restructuring: move code into domain modules, introduce domain events to break direct coupling, and document the architecture in living ADRs that live alongside the code. The goal is a codebase where any engineer can confidently change any module without reading the entire system first.