Skip to content
ExponaBLOG
NESTJS · DAY 087 / 100

When to split into microservices: lessons from production

Microservices solve organizational problems, not code problems.

Expona Admin8 min read

When to split into microservices

I spent two years watching a team try to solve architectural problems by breaking up their monolith, only to realize halfway through that they were chasing the wrong solution. The question isn't whether your codebase has grown large—it's whether your organization, operational needs, or customer requirements demand it. Microservices solve organizational problems, not code problems, and understanding that distinction will save you years of complexity and regret.

Different scaling needs

The most legitimate reason to split into microservices is when different parts of your system need to scale independently. In a ride-hailing platform I worked on, the payment processing service received traffic patterns completely different from the driver-location service. During peak hours, location updates happened thousands of times per second, but payments were bursty and latency-sensitive.

If we'd kept them in a single monolith, we'd either over-provision for location traffic (wasting money) or under-provision for payment spikes (risking failures). With separate services, we could:

  • Run 50 instances of the location service with aggressive caching and Redis clustering
  • Run 5 instances of the payment service with stricter consistency guarantees and direct database connections
  • Scale each independently based on real demand patterns

This made sense because the scaling strategy for each was fundamentally different. Location service needed horizontal distribution across edge locations and high throughput. Payment service needed vertical strength and transactional guarantees.

Before you split, measure actual bottlenecks. Create a load test that mirrors your real traffic distribution. If you find that database connection pooling, CPU, or memory pressure comes from a specific feature, and that feature's scaling strategy differs from the rest of your system—that's a signal.

typescript

// Example: Location service prioritizes throughput
@Injectable()
export class LocationService {
  constructor(
    private redis: Redis,
    private kafka: KafkaProducerService
  ) {}

  async updateLocation(driverId: string, lat: number, lon: number) {
    // Write-through to cache first for speed
    await this.redis.geoadd(`drivers:location`, lon, lat, driverId);
    // Async event for persistence and analytics
    await this.kafka.emit('driver.location.updated', { driverId, lat, lon });
    return { success: true };
  }
}

// Example: Payment service prioritizes consistency
@Injectable()
export class PaymentService {
  constructor(private db: Database) {}

  async processPayment(rideId: string, amount: number) {
    const connection = await this.db.getConnection();
    try {
      await connection.query('BEGIN TRANSACTION');
      const ride = await connection.query(
        'SELECT * FROM rides WHERE id = $1 FOR UPDATE',
        [rideId]
      );
      if (ride.status !== 'completed') throw new Error('Invalid state');
      await connection.query(
        'INSERT INTO payments (ride_id, amount) VALUES ($1, $2)',
        [rideId, amount]
      );
      await connection.query('COMMIT');
      return { transactionId: uuid() };
    } catch (e) {
      await connection.query('ROLLBACK');
      throw e;
    }
  }
}

Independent teams that block each other

This is the organizational signal that matters most. If two teams can't deploy independently, can't choose their own tech stack, and spend half their meetings negotiating database schema changes, you have a problem—but it's not a code problem.

I watched a team of five backend engineers and another team of four frontend engineers share a single NestJS monolith. The backend team wanted to refactor the authentication module. The frontend team had a critical feature that depended on the auth API contract. Everything froze. Meetings multiplied. Small changes required coordination across both teams.

When you introduce microservices, you create clear API boundaries. The auth service team can refactor their internals freely. The user service team can scale their database independently. Teams communicate through stable contracts (OpenAPI specs, async messages) rather than shared code and databases.

The litmus test: Can team A deploy multiple times per day without coordinating with team B? If no, and if team B's work is genuinely independent (not just another feature in the same domain), microservices might reduce friction.

But there's a cost. Each team now owns a service's deployment, monitoring, and incidents. You need observability infrastructure that didn't exist in the monolith. You need clear runbooks and on-call rotations. A team of two cannot reasonably own three microservices.

The rule I follow: Only split a service if the team owning it will have at least three people who understand the full lifecycle (code, deploy, logs, incidents, on-call).

Different reliability or compliance requirements

Some parts of your system must be more reliable or compliant than others. In a trading platform I built, the execution engine needed five-nines availability and SEC audit trails. The recommendations service needed three-nines and could tolerate occasional delays.

Keeping them together meant:

  • The execution engine inherited the recommendations service's reliability problems
  • A database schema change for recommendations could block deployment for execution
  • Compliance audits examined all code in the monolith, even parts unrelated to trading regulations

By separating them, we could:

  • Run execution with multi-region failover and synchronous replication
  • Run recommendations with eventual consistency and single-region deployment
  • Subject execution to rigorous compliance and recommendations to lighter governance
  • Fail the recommendations service without affecting trading

This is a strong reason to split because the operational costs differ dramatically. A high-reliability service needs circuit breakers, bulkheads, and redundancy. A best-effort service needs none of that.

typescript

// High-reliability execution service
@Injectable()
export class ExecutionService {
  constructor(
    private db: Database,
    private circuitBreaker: CircuitBreaker,
    private auditLogger: AuditLogger
  ) {}

  async executeOrder(orderId: string, price: number) {
    try {
      await this.auditLogger.log('EXECUTION_START', { orderId, price });
      
      const connection = await this.db.getPrimaryConnection();
      await connection.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
      
      // Strict transaction handling
      const result = await connection.query(
        'INSERT INTO executions (order_id, price, status) VALUES ($1, $2, $3)',
        [orderId, price, 'EXECUTED']
      );
      
      await this.auditLogger.log('EXECUTION_COMMIT', { orderId });
      await connection.query('COMMIT');
      return result;
    } catch (error) {
      await this.auditLogger.log('EXECUTION_FAILED', { orderId, error });
      throw error;
    }
  }
}

// Best-effort recommendations service
@Injectable()
export class RecommendationsService {
  constructor(private cache: Redis, private http: HttpClient) {}

  async getRecommendations(userId: string) {
    const cached = await this.cache.get(`recs:${userId}`);
    if (cached) return JSON.parse(cached);
    
    // No circuit breaker—if this fails, just return empty
    const fresh = await this.http
      .get(`/ml/recommend/${userId}`)
      .toPromise()
      .catch(() => ({ items: [] }));
    
    await this.cache.setex(`recs:${userId}`, 3600, JSON.stringify(fresh));
    return fresh;
  }
}

Not because the codebase feels big

This is where most teams go wrong. A 50,000-line NestJS application is not a valid reason to split into microservices. Neither is "we have 15 modules" or "our build takes 3 minutes."

A large codebase is solved by better code organization, not by network calls. Split your monolith into logical layers and domains using NestJS modules, dependency injection, and clear folder structures. Use TypeScript interfaces to enforce boundaries. Add ESLint rules that prevent service A from importing directly from service B's internals.

If deployment speed is the problem, fix your CI/CD. Parallel testing, Docker layer caching, and incremental builds solve this in a monolith with none of the complexity microservices introduce.

I've seen teams with 100,000 lines in a single NestJS monolith that deployed in 90 seconds and had zero cascading failures. I've also seen teams with four microservices who spent more time debugging distributed tracing than building features.

The cost of microservices is real and often hidden:

  • Operational complexity: Distributed logging, tracing, monitoring, and on-call
  • Data consistency: No more ACID guarantees across services. You need sagas, event sourcing, or eventual consistency
  • Testing: Integration tests become harder. You need contract tests and consumer-driven tests
  • Latency: Network calls replace in-process function calls. Every boundary adds milliseconds
  • Team overhead: Small teams will spend more time on infrastructure than features

Common mistakes

Splitting too early: Most teams split when they have one team of 6 engineers. Wait until you have at least three independent teams with conflicting scaling or reliability needs.

Premature optimization: "What if we need to scale the auth service to 10,000 RPS?" You don't have that problem yet. A well-designed monolith scales to surprising throughput. Fix the actual constraint you have today.

Ignoring distributed complexity: A microservice architecture is not simpler. It's more complex, but the complexity is in different places. Don't trade monolith complexity for distributed complexity without a clear win.

Creating too many services: A team of four owning three services means each service gets shallow attention. Start with bigger services. You can always split later when the pain is real and the team is large enough to own new services.

Treating microservices as a code quality solution: Splitting won't make your code cleaner or your tests better. If your team struggles with testing or design in a monolith, they'll struggle more in microservices.

Key takeaways

  • Split into microservices when you have independent scaling needs, independent teams that block each other, or different reliability requirements—not because your codebase feels large
  • Measure real bottlenecks before deciding. Load tests and profiling reveal the actual constraint
  • Only split if the owning team has at least three experienced people. Microservices require ownership across code, deploy, monitoring, and incidents
  • Recognize that microservices solve organizational and operational problems at the cost of architectural and distributed systems complexity
  • A well-organized monolith with clear boundaries, good testing, and fast CI/CD beats a poorly organized microservice architecture every time
  • Splitting decisions should be driven by business and organizational reality, not architecture trends or "best practices"
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles