October 18, 2025

The Modern Monolith 2/4: Building a Modular Monolith

Structure without distribution. Clear boundaries enforced at dev time. Here's how to build a monolith that won't trap you later.

ArchitectureMonolithMicroservicesSoftware EngineeringImplementation

The argument against monoliths assumes they're unstructured. A single codebase where everything touches everything else. No boundaries, no discipline, just accumulating mess.

That's just bad engineering. A modular monolith gives you the same structural clarity as microservices—bounded contexts, explicit interfaces, domain separation—without the operational cost of network boundaries.

What Modularity Actually Means

Modularity isn't about separate processes. It's about separate concerns. In a well-structured monolith, your billing logic doesn't reach into your notification logic. Each module has a clear boundary and a public interface.

The difference from microservices is enforcement. Microservices enforce boundaries at runtime through the network. Modular monoliths enforce them at dev time through linting, architecture tests, and code review. When you violate a boundary in a monolith, your CI build breaks. When you violate one in microservices, your service degrades in production.

Start With Domain Boundaries

The first step isn't code. It's understanding what your system does.

Use domain-driven design to identify core domains. Not CRUD entities—actual business capabilities. Billing is the capability to charge customers, track payments, and handle refunds. Projects is the capability to organize work, assign tasks, and track progress.

List your domains. For a project management tool: projects, users, notifications, billing, analytics. For e-commerce: catalog, cart, orders, payments, fulfillment. Keep the list small. If you have more than eight domains, you're probably slicing too thin.

These domains become your modules. Each gets a folder and a clear responsibility.

Structure Your Codebase

Organize code by business capability, not technical layer. Instead of controllers/, services/, repositories/, you have billing/, projects/, notifications/.

src/
  modules/
    billing/
      domain/        # Business logic
      handlers/      # HTTP/CLI entry points
      repository/    # Data access
      index.ts       # Public API
    projects/
      domain/
      handlers/
      repository/
      index.ts
  shared/
    db/
    types/

The index.ts file is your public API. It exports only what other modules need. Everything else stays internal.

// modules/billing/index.ts
export async function createInvoice(
  userId: string,
  amount: number,
  description: string
): Promise<Invoice> {
  // implementation
}

export async function getBalance(userId: string): Promise<number> {
  // implementation
}

// Everything else is internal

Other modules import from here:

// modules/projects/handlers/createProject.ts
import { createInvoice } from '../../billing';

export async function createProject(req, reply) {
  const project = await projectRepo.create(req.body);
  await createInvoice(project.userId, project.plan.price, 'Project subscription');
  return project;
}

The projects module doesn't know how billing works internally. It just calls the public function.

Enforce With Tooling

You need tooling to keep boundaries clear as the codebase grows. ESLint can enforce import restrictions:

// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [{
        group: ['**/modules/*/domain/*', '**/modules/*/repository/*', '**/modules/*/handlers/*'],
        message: 'Import from module index only'
      }]
    }]
  }
};

Write architecture tests that verify boundaries:

// tests/architecture.test.ts
describe('Module boundaries', () => {
  it('should not import across module internals', () => {
    const projectsFiles = glob.sync('src/modules/projects/**/*.ts');
    projectsFiles.forEach(file => {
      const content = fs.readFileSync(file, 'utf-8');
      const illegalImports = content.match(/from ['"].*\/modules\/billing\/(domain|repository|handlers)/);
      expect(illegalImports).toBeNull();
    });
  });
});

Shopify built an internal tool called Wedge to track component isolation across their massive Rails codebase. It hooks into Ruby's tracepoints during CI to build a call graph and flags violations.

Database Strategy

Start with a single database but enforce logical separation. Use PostgreSQL schemas to group tables by module:

CREATE SCHEMA billing;
CREATE SCHEMA projects;

CREATE TABLE billing.invoices (...);
CREATE TABLE projects.projects (...);

Modules only access their own schema. If you need data from another module, call its public API. This keeps boundaries clean. If you later split databases, the code doesn't change.

Using Platformatic

Platformatic is a Node.js framework built for this model. It lets you structure applications as composable services that run in a single process. You get the modularity of microservices with the deployment simplicity of a monolith.

With Platformatic, you define each module as a service with its own routes and schema. The framework handles inter-service communication in-process. No network latency. No distributed tracing. Just function calls with clean boundaries. You get type safety, OpenAPI docs, and comprehensive NFR management without building infrastructure yourself.

Migration Strategy

If you're starting fresh, build modules from day one. If you're refactoring an existing monolith, go incrementally.

Pick one feature. Extract it into a module. Draw the boundary, define the public API, move the code. Test that everything works. Then pick the next feature.

Don't try to modularize everything at once. That's a rewrite, and rewrites fail. Carve out modules one at a time. Focus on high-churn areas first—the code that changes most frequently benefits most from clear boundaries.

What Success Looks Like

You know it's working when:

  • New engineers contribute to a module without understanding the entire system
  • Features touch one module 80% of the time
  • Build times stay reasonable as the codebase grows
  • Refactoring within a module doesn't break other modules
  • Extracting a service later would be straightforward

A modular monolith isn't about perfection. It's about making your system easier to change over time.

Why This Matters

A modular monolith delivers the primary benefit of good architecture: the ability to understand and change systems without understanding everything. When you work on billing, you don't need to know how notifications work. You just need to know the interface.

You also gain migration optionality. Because boundaries are explicit, extracting a service later doesn't require a rewrite. You've done the hard work of defining interfaces and eliminating coupling. Moving to a separate process is a deployment change, not an architectural one.

The modular monolith isn't a stepping stone to microservices. It's a legitimate endpoint. Many systems will never need to split into services, and that's alignment between architecture and actual requirements, not failure.

Try This

Pick one feature in your current codebase. Draw a box around all the code it needs: routes, business logic, data access. What does it depend on from outside that box? Could those dependencies be expressed as a clean interface with a handful of functions?

That's your first module boundary. Create a folder, move the code, write an index.ts that exports only what other modules need. Run your tests. If they pass, you've created your first module.

Next

3/4: When to Stay, When to Split

Sources

Enjoyed this article?

Check out more articles or connect with me