Architecture

From Monolith to Microservices: Scaling Node.js with Turborepo

Migrating a legacy monolithic backend to a scalable, distributed microservices architecture using NestJS and Monorepos.

February 8, 2025
3 min read
From Monolith to Microservices: Scaling Node.js with Turborepo

Introduction

Legacy systems often reach a breaking point. In a recent project, I was tasked with modernizing a critical backend infrastructure that had outgrown its monolithic roots. The goal was clear: improve scalability, decouple dependencies, and increase developer velocity without breaking existing client applications.

This article details the strategy and technical execution of migrating a heavy Node.js monolith into a streamlined microservices ecosystem managed by Turborepo.

The Challenge: A Tightly Coupled Monolith

The original system was a standard monolithic application. While it served its purpose initially, it faced several bottlenecks:

  • Slow Deployment Cycles: Updating a small feature required deploying the entire application.
  • Fragile Dependencies: A bug in the reporting module could bring down the core transactional API.
  • Cognitive Load: New developers struggled to navigate the massive codebase.
graph TB
    subgraph "Legacy Monolith"
        API["Monolithic API"]
        Auth["Auth Module"]
        Reports["Reports Module"]
        Transactions["Transactions Module"]
        Database[("Single Database")]
        
        API --> Auth
        API --> Reports
        API --> Transactions
        Auth -.tight coupling.-> Transactions
        Reports -.tight coupling.-> Auth
        Transactions -.tight coupling.-> Reports
        Auth --> Database
        Reports --> Database
        Transactions --> Database
    end
    
    style API fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style Auth fill:#ffa94d,stroke:#d9480f
    style Reports fill:#ffa94d,stroke:#d9480f
    style Transactions fill:#ffa94d,stroke:#d9480f

The Solution: A Turborepo-Managed Ecosystem

To solve this, I didn’t just split the code; I re-architected the developer experience. I chose Turborepo to manage the new monorepo structure. This allowed me to keep distinct services isolated while easily sharing common logic (utilities, types, configurations).

1. Hexagonal Architecture (Ports & Adapters)

I adopted Hexagonal Architecture to decouple the business logic from external frameworks and tools.

  • Domain: Pure business logic, no external dependencies.
  • Ports: Interfaces defining how data enters/exits the domain.
  • Adapters: Implementations for Databases (PostgreSQL/MongoDB) and APIs.

This structure ensured that migrating from one database to another, or swapping 3rd party integrations, didn’t touch the core business rules.

graph LR
    subgraph "Turborepo Monorepo"
        subgraph "Applications"
            Gateway["API Gateway"]
            Auth["Auth Service"]
            Reports["Reports Service"]
            Transactions["Transactions Service"]
        end
        
        subgraph "Shared Packages"
            Common["@packages/common"]
            ESLint["@packages/eslint-config"]
            DB["@packages/database"]
        end
        
        Gateway --> Auth
        Gateway --> Reports
        Gateway --> Transactions
        
        Auth -.uses.-> Common
        Auth -.uses.-> DB
        Reports -.uses.-> Common
        Reports -.uses.-> DB
        Transactions -.uses.-> Common
        Transactions -.uses.-> DB
    end
    
    PG[("PostgreSQL")]
    Mongo[("MongoDB")]
    
    Auth --> PG
    Reports --> Mongo
    Transactions --> PG
    
    style Gateway fill:#51cf66,stroke:#2f9e44,color:#000
    style Auth fill:#74c0fc,stroke:#1c7ed6
    style Reports fill:#74c0fc,stroke:#1c7ed6
    style Transactions fill:#74c0fc,stroke:#1c7ed6
    style Common fill:#ffa94d,stroke:#d9480f
    style ESLint fill:#ffa94d,stroke:#d9480f
    style DB fill:#ffa94d,stroke:#d9480f

2. Standardization via Shared Libraries

One of the biggest risks in microservices is code duplication. Using Turborepo, I created internal packages:

  • @packages/common: Shared DTOs and utility functions.
  • @packages/eslint-config: Enforcing strict coding standards across all services.
  • @packages/database: Prisma/TypeORM shared schemas.
// Example of importing a shared internal package in a microservice
import { StandardResponse } from '@internal/common';
import { SecureLogger } from '@internal/logger';

Critical Wins

API Versioning & Backward Compatibility

Since there were legacy mobile clients that couldn’t be forced to update immediately, implementing a strict URL-based versioning strategy was crucial. I utilized NestJS’s versioning capabilities to run v1 (legacy adapters) and v2 (new microservices) in parallel.

Security Hardening

The migration was also an opportunity to audit security. I implemented standardized Guard Middleware across the gateway, ensuring that authentication and authorization (RBAC) were consistent for every microservice.

Conclusion

Migrating to microservices isn’t just about splitting files; it’s about defining boundaries. By leveraging Turborepo and NestJS, I transformed a fragile legacy system into a robust, scalable platform that allowed the business to innovate faster.

Tags

#Microservices #NestJS #Turborepo #System Design
Rommel

Written by Rommel Saquicela

Senior Tech Lead & Solutions Architect

View Profile