Boumlik BrahimBoumlik Brahim
Back to Journal
Architecture

Software Architecture: Monolith vs Microservices Decision Guide

A comprehensive guide to choosing between monolith and microservices architectures. Covers decision frameworks, modular monolith patterns, migration strategies, and production considerations for each approach.

Every software project begins with a fundamental architectural decision: how should we structure our application? This choice impacts development velocity, operational complexity, team organization, and long-term maintainability. In this guide, I'll break down software architecture patterns, with deep focus on monolith and microservices, the two architectures I've worked with extensively in production.

Why Architecture Matters

Architecture is not about choosing the "best" pattern. It's about choosing the right pattern for your context. A startup with three developers has different needs than an enterprise with fifty teams. A real-time trading platform has different requirements than a content management system.

The wrong architectural choice leads to:

  • Premature complexity: Building microservices when a monolith would suffice
  • Scaling bottlenecks: Hitting monolith limits when you need independent scaling
  • Team friction: Architecture that doesn't match your organization structure
  • Technical debt: Patterns that fight against your domain's natural boundaries

The Monolith Architecture

A monolith is a single deployable unit containing all application functionality. The entire codebase lives in one repository, compiles into one artifact, and deploys as one process.

MONOLITH ARCHITECTURE

┌─────────────────────────────────────────────────────┐
│                    Load Balancer                    │
└─────────────────────────┬───────────────────────────┘
                          │
┌─────────────────────────▼───────────────────────────┐
│                  Monolith Application               │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐   │
│  │   User      │ │   Order     │ │  Payment    │   │
│  │   Module    │ │   Module    │ │  Module     │   │
│  └──────┬──────┘ └──────┬──────┘ └──────┬──────┘   │
│         │               │               │          │
│  ┌──────▼───────────────▼───────────────▼──────┐   │
│  │            Shared Data Layer                │   │
│  └─────────────────────┬───────────────────────┘   │
└─────────────────────────┼───────────────────────────┘
                          │
┌─────────────────────────▼───────────────────────────┐
│              Single Database (PostgreSQL)           │
└─────────────────────────────────────────────────────┘

Characteristics of Monoliths

  • Single codebase: All code in one repository, one build process
  • Shared memory: Modules communicate through function calls, not network
  • Single database: All modules share the same database instance
  • Atomic deployment: Everything deploys together or not at all
  • Unified technology: One language, one framework, one runtime

Benefits of Monoliths

Simplicity in Development

Everything is in one place. No network calls between services, no distributed debugging, no service discovery. You can trace a request from HTTP entry to database query in a single debugger session.


@Injectable()
export class OrderService {
  constructor(
    private userService: UserService,
    private paymentService: PaymentService,
    private inventoryService: InventoryService,
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    const user = await this.userService.findById(userId);
    const inventory = await this.inventoryService.checkAvailability(items);
    const payment = await this.paymentService.processPayment(user, items);

    return this.orderRepository.create({
      userId,
      items,
      paymentId: payment.id,
    });
  }
}

In a monolith, this is a simple function call chain. No network latency, no serialization overhead, no partial failure scenarios.

Easier Testing

Integration tests run against a single application. No need to spin up multiple services, mock external APIs, or handle distributed test data setup.


describe("OrderService", () => {
  let orderService: OrderService;
  let module: TestingModule;

  beforeEach(async () => {
    module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    orderService = module.get(OrderService);
  });

  it("should create order with payment", async () => {
    const order = await orderService.createOrder("user-123", mockItems);

    expect(order.paymentId).toBeDefined();
    expect(order.status).toBe("CONFIRMED");
  });
});

Straightforward Deployment

One artifact to build, one deployment to manage, one rollback if things go wrong. No coordination between services, no API version compatibility concerns.

ACID Transactions

Database transactions span the entire operation. If payment fails, the order doesn't exist. No eventual consistency, no compensating transactions, no distributed saga patterns.


@Injectable()
export class OrderService {
  constructor(private prisma: PrismaService) {}

  async createOrderWithTransaction(userId: string, items: OrderItem[]) {
    return this.prisma.$transaction(async (tx) => {
      const inventory = await tx.inventory.decrementMany(items);
      const payment = await tx.payment.create({ data: { userId, amount: total } });
      const order = await tx.order.create({
        data: { userId, items, paymentId: payment.id },
      });

      return order;
    });
  }
}

Challenges of Monoliths

Scaling Limitations

You scale everything or nothing. If the payment module needs 10x capacity but user management needs 1x, you still deploy 10 instances of the entire application.

Technology Lock-in

The entire application uses one stack. If Python is better for your ML features but your monolith is Node.js, you have limited options.

Deployment Risk

A bug in any module can take down the entire application. A memory leak in user avatars affects payment processing.

Team Coupling

Large teams working on one codebase leads to merge conflicts, deployment coordination, and unclear ownership boundaries.

Build Time Growth

As the codebase grows, build and test times increase. What started as 2-minute builds becomes 30-minute CI pipelines.

The Microservices Architecture

Microservices decompose an application into small, independently deployable services. Each service owns its domain, its data, and its deployment lifecycle.

MICROSERVICES ARCHITECTURE

┌─────────────────────────────────────────────────────┐
│                    Load Balancer                    │
└─────────────────────────┬───────────────────────────┘
                          │
┌─────────────────────────▼───────────────────────────┐
│                    API Gateway                      │
│         • Authentication    • Rate Limiting         │
│         • Request Routing   • API Composition       │
└───────┬─────────────┬─────────────┬─────────────────┘
        │             │             │
        │     TCP     │     TCP     │
   ┌────▼────┐   ┌────▼────┐   ┌────▼────┐
   │  User   │   │  Order  │   │ Payment │
   │ Service │   │ Service │   │ Service │
   └────┬────┘   └────┬────┘   └────┬────┘
        │             │             │
   ┌────▼────┐   ┌────▼────┐   ┌────▼────┐
   │ User DB │   │Order DB │   │Payment  │
   │(Postgres)│   │(MongoDB)│   │   DB    │
   └─────────┘   └─────────┘   └─────────┘

Characteristics of Microservices

  • Service independence: Each service is a separate codebase, deployable unit
  • Database per service: Each service owns its data, no shared databases
  • Network communication: Services communicate via HTTP, TCP, or message queues
  • Technology diversity: Each service can use different languages and frameworks
  • Independent scaling: Scale each service based on its specific load

NestJS Microservices Implementation

Here's how I implement microservices with NestJS using TCP transport for synchronous communication.

API Gateway Setup

The API Gateway is the single entry point exposed to the internet. It handles HTTP requests and routes them to appropriate microservices via TCP.


import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { ValidationPipe } from "@nestjs/common";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  const config = new DocumentBuilder()
    .setTitle("Microservices API")
    .setDescription("API Gateway for microservices architecture")
    .setVersion("1.0")
    .addBearerAuth()
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("api", app, document);

  app.enableCors();
  await app.listen(3001);
}
bootstrap();

Registering Microservice Clients

The gateway needs to know how to reach each microservice. Using registerAsync ensures environment variables are properly loaded.


import { Module } from "@nestjs/common";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { ConfigModule, ConfigService } from "@nestjs/config";

@Module({
  imports: [
    ConfigModule.forRoot(),
    ClientsModule.registerAsync([
      {
        name: "USER_SERVICE",
        imports: [ConfigModule],
        useFactory: (configService: ConfigService) => ({
          transport: Transport.TCP,
          options: {
            host: configService.get("USER_SERVICE_HOST"),
            port: configService.get("USER_SERVICE_PORT"),
          },
        }),
        inject: [ConfigService],
      },
      {
        name: "ORDER_SERVICE",
        imports: [ConfigModule],
        useFactory: (configService: ConfigService) => ({
          transport: Transport.TCP,
          options: {
            host: configService.get("ORDER_SERVICE_HOST"),
            port: configService.get("ORDER_SERVICE_PORT"),
          },
        }),
        inject: [ConfigService],
      },
    ]),
  ],
})
export class AppModule {}

Gateway Service Communication

The gateway service uses ClientProxy to communicate with microservices.


import { Injectable, Inject, HttpException, HttpStatus } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom, timeout, catchError } from "rxjs";

@Injectable()
export class OrderGatewayService {
  constructor(
    @Inject("ORDER_SERVICE") private orderService: ClientProxy,
    @Inject("USER_SERVICE") private userService: ClientProxy,
  ) {}

  async createOrder(userId: string, createOrderDto: CreateOrderDto) {
    try {
      const user = await firstValueFrom(
        this.userService.send({ cmd: "findUser" }, { userId }).pipe(
          timeout(5000),
          catchError((error) => {
            throw new HttpException(
              error.message || "User service unavailable",
              error.status || HttpStatus.SERVICE_UNAVAILABLE,
            );
          }),
        ),
      );

      const result = await firstValueFrom(
        this.orderService.send({ cmd: "createOrder" }, { user, ...createOrderDto }).pipe(
          timeout(5000),
        ),
      );

      return result;
    } catch (error) {
      if (error instanceof HttpException) throw error;
      throw new HttpException(
        "Failed to communicate with microservice",
        HttpStatus.SERVICE_UNAVAILABLE,
      );
    }
  }
}

Microservice Bootstrap

Each microservice listens for TCP messages instead of HTTP requests.


import { NestFactory } from "@nestjs/core";
import { MicroserviceOptions, Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";

async function bootstrap() {
  const app = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.TCP,
      options: {
        host: process.env.MS_HOST,
        port: parseInt(process.env.MS_PORT, 10),
      },
    },
  );

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
  }));

  await app.listen();
}
bootstrap();

Message Pattern Controllers

Microservice controllers use @MessagePattern() instead of HTTP decorators.


import { Controller } from "@nestjs/common";
import { MessagePattern, Payload, RpcException } from "@nestjs/microservices";

@Controller()
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @MessagePattern({ cmd: "createOrder" })
  async createOrder(@Payload() data: { user: User; items: OrderItem[] }) {
    try {
      return await this.orderService.create(data.user, data.items);
    } catch (error) {
      throw new RpcException({
        status: error.status || 500,
        message: error.message || "Failed to create order",
      });
    }
  }

  @MessagePattern({ cmd: "findOrder" })
  async findOrder(@Payload() data: { id: string }) {
    const order = await this.orderService.findOne(data.id);
    if (!order) {
      throw new RpcException({
        status: 404,
        message: "Order not found",
      });
    }
    return order;
  }
}

Communication Patterns

Microservices communicate through two primary patterns: synchronous and asynchronous.

Synchronous Communication (Request-Response)

Use send() when the caller needs an immediate response. The gateway waits for the microservice to process and return data.


const user = await firstValueFrom(
  this.userService.send({ cmd: "findUser" }, { userId }).pipe(
    timeout(5000),
  ),
);

Use for: User authentication, data fetching, form submissions, any operation where the user waits for a response.

Asynchronous Communication (Events)

Use emit() when the caller doesn't need to wait. The event is fired and the service continues without waiting for processing.


@Injectable()
export class OrderService {
  constructor(
    @Inject("NOTIFICATION_SERVICE") private notificationClient: ClientProxy,
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    const order = await this.orderRepository.create({ userId, items });

    this.notificationClient.emit("order.created", {
      orderId: order.id,
      userId,
      items,
    });

    return order;
  }
}

Use for: Sending emails, generating reports, analytics tracking, audit logging.

Event Handler in Microservice


@Controller()
export class NotificationController {
  @EventPattern("order.created")
  async handleOrderCreated(@Payload() data: OrderCreatedEvent) {
    await this.emailService.sendOrderConfirmation(data.userId, data.orderId);
  }
}

Benefits of Microservices

Independent Deployment

Deploy the payment service without touching user management. Ship features faster by removing deployment coordination overhead.

Targeted Scaling

Scale services independently based on demand. Payment processing needs 20 instances during Black Friday while user management stays at 2.

Technology Freedom

Use the right tool for each job. Node.js for real-time features, Python for ML pipelines, Go for high-performance services.

Fault Isolation

A bug in the recommendation service doesn't crash checkout. Services fail independently, and the system degrades gracefully.

Challenges of Microservices

Distributed System Complexity

Network calls fail. Services timeout. Partial failures create inconsistent states. You need resilience patterns like timeouts, retries, and circuit breakers.

Data Consistency

No more ACID transactions across services. You need eventual consistency, saga patterns, and compensating transactions.

Operational Overhead

More services mean more deployments, more monitoring, more logs to aggregate, more infrastructure to manage.

Production Considerations

Running microservices in production requires additional infrastructure beyond what a monolith needs.

Service Discovery

Services need to find each other. In containerized environments, use Kubernetes DNS or service mesh solutions like Istio.

Distributed Tracing

A single request might touch 10 services. Tools like Jaeger or Zipkin help trace requests across service boundaries using correlation IDs.

Centralized Logging

Aggregating logs from dozens of services into a single searchable interface. ELK stack (Elasticsearch, Logstash, Kibana) or cloud-native solutions.

Health Checks

Each service needs health endpoints for container orchestration to know when to restart or replace instances.


import { Controller, Get } from "@nestjs/common";
import { HealthCheck, HealthCheckService, PrismaHealthIndicator } from "@nestjs/terminus";

@Controller("health")
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private prisma: PrismaHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.prisma.pingCheck("database"),
    ]);
  }
}

Event-Driven Architecture

Event-driven architecture is a pattern where services communicate through events rather than direct calls. It's often combined with microservices to achieve loose coupling.

EVENT-DRIVEN ARCHITECTURE

┌─────────────┐     ┌─────────────────────┐     ┌─────────────┐
│   Order     │────▶│    Message Broker   │────▶│Notification │
│   Service   │     │     (RabbitMQ)      │     │   Service   │
└─────────────┘     └──────────┬──────────┘     └─────────────┘
                               │
                    ┌──────────┴──────────┐
                    ▼                     ▼
             ┌─────────────┐       ┌─────────────┐
             │  Inventory  │       │  Analytics  │
             │   Service   │       │   Service   │
             └─────────────┘       └─────────────┘

When to Use Event-Driven

  • Decoupled services: Publishers don't need to know about subscribers
  • Async workflows: Long-running processes that don't need immediate response
  • Multiple consumers: One event needs to trigger actions in multiple services
  • Audit trails: Events provide natural logging of what happened

NestJS with RabbitMQ


@Module({
  imports: [
    ClientsModule.register([
      {
        name: "EVENTS",
        transport: Transport.RMQ,
        options: {
          urls: [process.env.RABBITMQ_URL],
          queue: "events_queue",
          queueOptions: { durable: true },
        },
      },
    ]),
  ],
})
export class EventsModule {}

Common Anti-Patterns

Microservices introduce complexity. These anti-patterns turn that complexity into failure.

The Distributed Monolith

Services that must be deployed together, share the same database, or have tight runtime coupling. You get the complexity of microservices with none of the benefits.

Signs:

  • Deploying one service requires deploying others
  • Services share database tables
  • Circular dependencies between services
  • One service failure cascades to all others

Shared Database

Multiple services reading and writing to the same database tables. This creates hidden coupling and makes independent deployment impossible.

ANTI-PATTERN: SHARED DATABASE

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│   Service   │  │   Service   │  │   Service   │
│      A      │  │      B      │  │      C      │
└──────┬──────┘  └──────┬──────┘  └──────┬──────┘
       │                │                │
       └────────────────┼────────────────┘
                        ▼
              ┌─────────────────┐
              │ Shared Database │  ← Schema changes
              │                 │    break all services
              └─────────────────┘

Sync Call Chains

Service A calls B, which calls C, which calls D. Latency compounds, and any failure breaks the entire chain.

Solution: Use async events where possible, or aggregate data to reduce call depth.

Decision Framework: Monolith vs Microservices

Choose Monolith When

  • Small team (under 10 developers): The coordination overhead of microservices isn't justified
  • New product/startup: You don't know your domain boundaries yet
  • Simple domain: The application doesn't have natural service boundaries
  • Strong consistency required: ACID transactions are critical to your business
  • Limited DevOps capacity: You don't have the infrastructure expertise for distributed systems

Choose Microservices When

  • Large team (multiple teams): Independent deployment enables team autonomy
  • Well-understood domain: Clear boundaries exist between business capabilities
  • Different scaling needs: Parts of your system have vastly different load patterns
  • Technology diversity needed: Different problems require different tech stacks
  • Strong DevOps culture: You have the infrastructure and expertise to operate distributed systems

Signs You Need to Split the Monolith

  • Deployment frequency is limited by coordination overhead
  • Teams are stepping on each other's code
  • One module needs 10x resources while others need 1x
  • Build times exceed 30 minutes
  • A bug in one area frequently breaks unrelated features

The Evaluation Matrix

FACTOR                    MONOLITH         MICROSERVICES
────────────────────────────────────────────────────────
Development Speed         Faster           Slower initially
Deployment Complexity     Simple           Complex
Scaling Flexibility       Limited          High
Technology Freedom        Low              High
Team Independence         Low              High
Operational Overhead      Low              High
Data Consistency          Strong (ACID)    Eventual
Fault Isolation           None             High

The Modular Monolith: A Middle Ground

Before jumping to microservices, consider the modular monolith. It's a monolith with clear internal boundaries that can be extracted into services later if needed.

MODULAR MONOLITH

┌─────────────────────────────────────────────────────┐
│                  Monolith Application               │
│  ┌─────────────────────────────────────────────┐   │
│  │                 API Layer                    │   │
│  └──────┬──────────────┬──────────────┬────────┘   │
│         │              │              │            │
│  ┌──────▼──────┐ ┌─────▼─────┐ ┌─────▼─────┐      │
│  │    User     │ │   Order   │ │  Payment  │      │
│  │   Module    │ │  Module   │ │  Module   │      │
│  │  ┌───────┐  │ │ ┌───────┐ │ │ ┌───────┐ │      │
│  │  │Public │  │ │ │Public │ │ │ │Public │ │      │
│  │  │  API  │  │ │ │  API  │ │ │ │  API  │ │      │
│  │  └───────┘  │ │ └───────┘ │ │ └───────┘ │      │
│  │  ┌───────┐  │ │ ┌───────┐ │ │ ┌───────┐ │      │
│  │  │Private│  │ │ │Private│ │ │ │Private│ │      │
│  │  │ Impl  │  │ │ │ Impl  │ │ │ │ Impl  │ │      │
│  │  └───────┘  │ │ └───────┘ │ │ └───────┘ │      │
│  └─────────────┘ └───────────┘ └───────────┘      │
└────────────────────────────────────────────────────┘

Modular Monolith Principles

  • Public API only: Modules expose a public interface; internal implementation is private
  • No cross-module DB access: Each module owns its tables; other modules use the public API
  • Interface communication: Modules communicate through exported services, not direct imports
  • Separate schemas: Database tables are logically grouped by module ownership

@Module({
  imports: [
    UserModule,
    OrderModule,
    PaymentModule,
  ],
})
export class AppModule {}

@Module({
  controllers: [UserController],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
export class UserModule {}

@Module({
  imports: [UserModule],
  controllers: [OrderController],
  providers: [OrderService, OrderRepository],
  exports: [OrderService],
})
export class OrderModule {}

The modular monolith gives you the simplicity of a monolith with the option to extract services later. When a module needs independent scaling, you extract it. The clear boundaries make extraction straightforward.

Presentation Layer Patterns

These patterns organize code within a single application, focusing on UI and presentation logic separation. They're different from application architecture patterns like monolith/microservices.

Layered Architecture (N-Tier)

The most common architecture pattern. It structures an application into horizontal layers, each responsible for specific concerns.

LAYERED ARCHITECTURE

┌─────────────────────────────────┐
│      Presentation Layer         │  Controllers, Views
├─────────────────────────────────┤
│        Business Layer           │  Services, Domain Logic
├─────────────────────────────────┤
│       Persistence Layer         │  Repositories, ORM
├─────────────────────────────────┤
│        Database Layer           │  PostgreSQL, MongoDB
└─────────────────────────────────┘

When to use: Traditional enterprise applications, CRUD-heavy systems.

MVC (Model-View-Controller)

Separates data, presentation, and user interaction. Common in server-rendered web applications.

  • Model: Data and business logic
  • View: User interface and data display
  • Controller: Handles user input, updates model, selects view

MVP (Model-View-Presenter)

An evolution of MVC. The Presenter handles all presentation logic while the View remains passive.

  • Model: Data and domain logic
  • View: Passive UI that delegates to Presenter
  • Presenter: Manages state, handles logic, updates View

MVVM (Model-View-ViewModel)

Focuses on data binding. The ViewModel exposes data and commands that the View binds to directly.

  • Model: Data and business rules
  • View: UI that binds to ViewModel properties
  • ViewModel: Exposes bindable data and commands

When to use: Complex UIs with extensive data binding (Angular, React with state management, Android/iOS).

VIPER

Applies Clean Architecture to iOS. View, Interactor, Presenter, Entity, Router.

When to use: Large iOS applications with complex features.

Pattern Selection Guide

PATTERN          BEST FOR                    AVOID WHEN
─────────────────────────────────────────────────────────
Layered          Enterprise CRUD apps        High scalability needed
MVC              Server-rendered web         Complex UI interactions
MVP              Testable mobile apps        Simple prototypes
MVVM             Data-driven UIs             Simple forms
VIPER            Large iOS apps              Small projects
Microservices    Multiple teams, scaling     Early-stage products
Monolith         Startups, small teams       Enterprise scale

Conclusion

Architecture is about tradeoffs, not absolutes. The best architecture is the one that fits your current context while allowing evolution as your needs change.

  • Start with a monolith unless you have clear reasons for microservices
  • Build modular: Even in a monolith, maintain clear boundaries
  • Avoid anti-patterns: Distributed monoliths and shared databases defeat the purpose
  • Choose communication wisely: Sync for user-facing, async for background work
  • Invest in observability: Tracing, logging, and health checks are not optional in production

The goal is not to have the most sophisticated architecture. The goal is to have an architecture that enables your team to deliver value efficiently.

Boumlik BrahimBoumlik Brahim

Technical Lead & IT Architecture Expert. Architecting resilient cloud ecosystems and scalable, high-performance solutions.

boumlik01brahim@gmail.com

Explore

Connect

  • Boumlik-Brahim
  • brahim-boumlik
  • @BOUMLIKBRAHIM4
  • @brahimboumlik
  • Brahim Boumlik

Status

Location

Casablanca, Morocco

Download Resume

© 2026 Boumlik Brahim.

GitHubLinkedInTwitterInstagramFacebook