Microservices Architecture

Over the past decade, I've had the opportunity to work on both monolithic applications and microservice architectures. This transition wasn't just a change in code organization but a fundamental shift in how I approached software development. In this article, I'll share my personal experiences, insights, and lessons learned as I navigated the world of microservices, with a focus on TypeScript implementations.

What Are Microservices?

Microservices architecture is an approach to building applications as a collection of small, autonomous services that are:

  • Independently deployable: Each service can be deployed without affecting others

  • Loosely coupled: Services interact through well-defined interfaces

  • Organized around business capabilities: Services align with business domains rather than technical layers

  • Owned by small teams: Enabling autonomy and specialized knowledge

This contrasts sharply with the monolithic approach I used earlier in my career, where applications were built as single, tightly integrated units.

My First Encounter with Microservice Complexity

I vividly remember my first large-scale microservices project. We were replacing a monolithic e-commerce platform with a constellation of services. Initially, I was excited about the elegant architecture diagrams showing clean separation of concerns.

Reality hit when our team had to coordinate deployments across twenty services just to implement a "view order history" feature. What seemed straightforward in the monolith became a complex choreography in the microservices world.

A Tale of Two Architectures: TypeScript Examples

Let me illustrate the difference using TypeScript examples from projects I've worked on.

The Monolithic Approach

In our monolithic e-commerce application, everything lived in one codebase:

// src/modules/orders/OrderService.ts
export class OrderService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly paymentService: PaymentService,
    private readonly emailService: EmailService
  ) {}

  async placeOrder(userId: string, items: OrderItem[], paymentDetails: PaymentDetails): Promise<Order> {
    // Get user from the database
    const user = await this.userRepository.findById(userId);
    if (!user) throw new Error('User not found');
    
    // Check product availability
    for (const item of items) {
      const product = await this.productRepository.findById(item.productId);
      if (!product || product.stock < item.quantity) {
        throw new Error(`Product ${item.productId} is not available in requested quantity`);
      }
    }
    
    // Calculate total
    const total = items.reduce((sum, item) => {
      const product = await this.productRepository.findById(item.productId);
      return sum + (product.price * item.quantity);
    }, 0);
    
    // Process payment
    const paymentResult = await this.paymentService.processPayment(paymentDetails, total);
    if (!paymentResult.success) {
      throw new Error('Payment failed: ' + paymentResult.message);
    }
    
    // Create order
    const order = await this.orderRepository.create({
      userId,
      items,
      total,
      status: 'PAID',
      paymentId: paymentResult.paymentId,
    });
    
    // Update inventory
    for (const item of items) {
      await this.productRepository.decrementStock(item.productId, item.quantity);
    }
    
    // Send confirmation email
    await this.emailService.sendOrderConfirmation(user.email, order);
    
    return order;
  }
}

Everything was simple to understand—the entire flow was in one file, transactions were straightforward, and debugging was easy. However, as our codebase grew to over 500,000 lines, build times stretched to 20 minutes, and a small change in one module could unexpectedly break others.

The Microservices Approach

When we rebuilt using microservices, the same flow was distributed across multiple services:

User Service:

// user-service/src/controllers/UserController.ts
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  @Get(':id')
  async getUser(@Param('id') id: string): Promise<UserDto> {
    const user = await this.userService.findById(id);
    if (!user) {
      throw new NotFoundException('User not found');
    }
    return this.userService.toDto(user);
  }
}

Product Service:

// product-service/src/services/ProductService.ts
@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private productRepository: Repository<Product>,
    private eventBus: EventBus
  ) {}

  async checkAvailability(productId: string, quantity: number): Promise<boolean> {
    const product = await this.productRepository.findOne(productId);
    return product && product.stock >= quantity;
  }
  
  async decrementStock(productId: string, quantity: number): Promise<void> {
    const product = await this.productRepository.findOne(productId);
    if (!product || product.stock < quantity) {
      throw new Error(`Product ${productId} is not available in requested quantity`);
    }
    
    product.stock -= quantity;
    await this.productRepository.save(product);
    
    this.eventBus.publish(new ProductStockUpdated(productId, product.stock));
  }
}

Order Service:

// order-service/src/services/OrderService.ts
@Injectable()
export class OrderService {
  constructor(
    private readonly httpService: HttpService,
    @InjectRepository(Order)
    private orderRepository: Repository<Order>,
    private readonly messageBroker: MessageBroker
  ) {}

  async placeOrder(orderRequest: CreateOrderRequest): Promise<Order> {
    // Verify user exists via HTTP call
    const userResponse = await this.httpService.get(
      `${process.env.USER_SERVICE_URL}/users/${orderRequest.userId}`
    ).toPromise();
    
    // Check product availability via HTTP call
    for (const item of orderRequest.items) {
      const availabilityResponse = await this.httpService.get(
        `${process.env.PRODUCT_SERVICE_URL}/products/${item.productId}/availability?quantity=${item.quantity}`
      ).toPromise();
      
      if (!availabilityResponse.data.available) {
        throw new Error(`Product ${item.productId} is not available in requested quantity`);
      }
    }
    
    // Create preliminary order
    const order = await this.orderRepository.save({
      userId: orderRequest.userId,
      items: orderRequest.items,
      status: 'PENDING',
      total: orderRequest.total
    });
    
    // Send to payment service via message broker
    await this.messageBroker.publish('payment.process', {
      orderId: order.id,
      paymentDetails: orderRequest.paymentDetails,
      amount: order.total
    });
    
    return order;
  }
  
  @EventHandler('payment.completed')
  async handlePaymentCompleted(data: PaymentCompletedEvent): Promise<void> {
    const order = await this.orderRepository.findOne(data.orderId);
    order.status = 'PAID';
    order.paymentId = data.paymentId;
    await this.orderRepository.save(order);
    
    // Publish inventory update message
    await this.messageBroker.publish('order.confirmed', {
      orderId: order.id,
      items: order.items
    });
    
    // Request email service to send confirmation
    await this.messageBroker.publish('email.send', {
      type: 'ORDER_CONFIRMATION',
      userId: order.userId,
      orderId: order.id
    });
  }
}

Each service was now independently deployable and could be scaled separately. We could enhance the product catalog without touching the payment system. However, tracing a user request across multiple services became much more complex, and ensuring data consistency was an ongoing challenge.

The Pros and Cons I've Experienced Firsthand

Pros of Microservices

  1. Independent Deployability: The most significant advantage in my experience. When we added a new payment provider to our payment service, we didn't need to deploy any other services.

  2. Technology Diversity: Our team used Node.js with TypeScript for most services, but we implemented the recommendation engine in Python for better machine learning capabilities. This flexibility was impossible in our monolith days.

  3. Resilience: When our product catalog service experienced an outage, the rest of the application continued to function. Users could still place orders for items in their cart.

  4. Scaled Development: Multiple teams could work on different services simultaneously without stepping on each other's toes. Our payment team could sprint ahead independently of the catalog team.

  5. Focused Optimization: We could scale our order processing service during peak shopping seasons without wasting resources on other components.

Cons of Microservices

  1. Distributed System Complexity: This cannot be overstated. Debugging issues across service boundaries consumed much more time than in our monolithic system.

    // Example from our troubleshooting toolkit
    async function traceRequest(requestId: string): Promise<ServiceTrace[]> {
      // Query logs from multiple services to reconstruct the request flow
      const userServiceLogs = await logService.query({
        requestId,
        service: 'user-service'
      });
      
      const orderServiceLogs = await logService.query({
        requestId,
        service: 'order-service'
      });
      
      // And so on for all 12 services...
      
      // Then correlate all logs by timestamp
      return correlateTraces([
        ...userServiceLogs,
        ...orderServiceLogs,
        // ...more logs
      ]);
    }
  2. Data Consistency Challenges: Maintaining consistency across services was a significant challenge. We had to adopt event-driven patterns and eventual consistency.

    // Saga pattern implementation for order processing
    @Injectable()
    export class OrderSaga {
      @Saga()
      orderProcess = (events$: Observable<any>): Observable<Command> => {
        return events$.pipe(
          ofType(OrderCreatedEvent),
          map((event) => new ProcessPaymentCommand(event.orderId)),
          catchError((err) => {
            console.error('Payment failed', err);
            return of(new CancelOrderCommand(event.orderId));
          })
        );
      }
    }
  3. Operational Complexity: Instead of monitoring one application, we now monitored dozens, each with its own metrics, logs, and failure modes.

  4. Network Latency: Inter-service communication added significant latency to some operations. A simple "view my order" could require calls to 5+ services.

  5. Development Environment: Setting up a full development environment became complex. We eventually created a "minimal mode" that mocked most services:

    // dev-environment/src/mockServices.ts
    export async function startMockServices(): Promise<void> {
      // Start minimal versions of services for local development
      if (process.env.DEV_MODE === 'minimal') {
        await Promise.all([
          startMockUserService(),
          startMockProductService(),
          // Only start the services you're working with for real
        ]);
        
        console.log('Started mock services in minimal mode');
      }
    }

When to Choose Microservices

After several years working with both architectures, here's my guidance on when to consider microservices:

  1. Choose microservices when:

    • Your organization has multiple teams that need to work independently

    • Different components of your application have vastly different scaling needs

    • You need to deploy updates frequently without downtime

    • Your application is sufficiently complex to warrant the overhead

  2. Stick with a monolith when:

    • You're building an MVP or startup where speed of development is critical

    • Your team is small and can effectively work on a shared codebase

    • The domain boundaries in your application aren't clear yet

    • You don't have experience managing distributed systems

My Practical Approach: The Modular Monolith First Strategy

The approach I now recommend to teams is what I call the "Modular Monolith First" strategy:

  1. Start with a well-structured monolith organized into clear modules with explicit boundaries:

    // A modular monolith structure
    /src
      /modules
        /users
          /api         // Express/NestJS controllers
          /application // Application services
          /domain      // Domain model
          /infrastructure // Repository implementations
        /products
          /api
          /application
          /domain
          /infrastructure
        /orders
          /api
          /application
          /domain
          /infrastructure
      /shared
        /infrastructure
          /database
          /messaging
          /logging
      /server.ts
  2. Enforce module boundaries through architecture rules, even in the monolith:

    // Using NestJS modules to enforce boundaries
    @Module({
      imports: [SharedModule],
      controllers: [ProductController],
      providers: [ProductService, ProductRepository],
      exports: [ProductService] // Explicitly control what's exposed
    })
    export class ProductModule {}
  3. Extract services when there's a clear business or technical need:

    // Example of a clean interface that could later become a service boundary
    export interface ProductCatalogService {
      findProducts(criteria: SearchCriteria): Promise<Product[]>;
      getProduct(id: string): Promise<Product>;
      createProduct(product: ProductCreateDto): Promise<Product>;
      // ...
    }

This approach has given me the best of both worlds: the simplicity of a monolith with the option to evolve toward microservices where it makes sense.

My journey with microservices has taught me that software architecture isn't about following trends—it's about making intentional tradeoffs based on your specific context.

Microservices offer undeniable benefits for large, complex systems with multiple development teams. But they come with significant costs in terms of operational complexity, debugging challenges, and development overhead.

The most successful projects I've worked on weren't dogmatic about architecture. They pragmatically chose the right approach for each component, sometimes mixing monolithic and microservice patterns where appropriate.

As with most architectural decisions, there's no universal right answer—just tradeoffs that make sense for your specific situation, team, and business goals.

Last updated