TechLead
Lesson 4 of 27
5 min read
Software Architecture

Microservices Fundamentals

Learn when to use microservices, how to define service boundaries, and communication patterns between services

What are Microservices?

Microservices architecture structures an application as a collection of small, autonomous services modeled around a business domain. Each service is independently deployable, scalable, and maintainable. Services communicate over well-defined APIs, typically HTTP/REST or messaging protocols.

Defining Characteristics

  • Single Responsibility: Each service handles one business capability (orders, payments, inventory)
  • Independent Deployment: Services can be deployed without coordinating with other services
  • Decentralized Data: Each service owns its data store — no shared databases
  • Technology Agnostic: Teams can choose the best technology stack for each service
  • Resilient: Failure of one service should not cascade to the entire system

Service Boundaries

Defining the right boundaries is the most critical decision in microservices. Bad boundaries lead to distributed monoliths — all the complexity of microservices with none of the benefits. Use Domain-Driven Design bounded contexts as the primary tool for finding service boundaries.

Boundary Identification Techniques

Technique Description When to Use
Bounded ContextsUse DDD context maps to find natural boundariesComplex domains with domain experts
Business CapabilitiesMap services to what the business does (not how)Well-understood business processes
Event StormingCollaborative workshop to discover events and commandsNew projects, team alignment
Team OwnershipAlign services with team structure (Conway's Law)Organizational optimization

Communication Patterns

Microservices communicate either synchronously (request-response) or asynchronously (event-driven). The choice depends on the coupling requirements, latency needs, and consistency model you are willing to accept.

Synchronous Communication

// Order Service calling Inventory Service via REST
import axios from "axios";

interface InventoryCheckResult {
  available: boolean;
  reservationId?: string;
}

export class InventoryClient {
  constructor(private readonly baseUrl: string) {}

  async checkAvailability(
    productId: string,
    quantity: number
  ): Promise {
    try {
      const response = await axios.post(
        `${this.baseUrl}/api/inventory/check`,
        { productId, quantity },
        { timeout: 3000 }
      );
      return response.data;
    } catch (error) {
      // Circuit breaker or fallback logic
      if (axios.isAxiosError(error) && error.code === "ECONNABORTED") {
        throw new ServiceUnavailableError("Inventory service timeout");
      }
      throw error;
    }
  }

  async reserveStock(
    productId: string,
    quantity: number,
    orderId: string
  ): Promise {
    const response = await axios.post(
      `${this.baseUrl}/api/inventory/reserve`,
      { productId, quantity, orderId }
    );
    return response.data.reservationId;
  }
}

Asynchronous Communication

// Order Service publishing events
import { Kafka, Producer } from "kafkajs";

export class OrderEventPublisher {
  private producer: Producer;

  constructor(kafka: Kafka) {
    this.producer = kafka.producer();
  }

  async connect(): Promise {
    await this.producer.connect();
  }

  async publishOrderPlaced(event: OrderPlacedEvent): Promise {
    await this.producer.send({
      topic: "orders.placed",
      messages: [{
        key: event.orderId,
        value: JSON.stringify({
          eventType: "OrderPlaced",
          orderId: event.orderId,
          customerId: event.customerId,
          items: event.items,
          totalAmount: event.totalAmount,
          occurredAt: new Date().toISOString(),
        }),
        headers: {
          "event-type": "OrderPlaced",
          "correlation-id": event.correlationId,
        },
      }],
    });
  }
}

// Inventory Service consuming events
export class InventoryEventConsumer {
  constructor(
    private readonly kafka: Kafka,
    private readonly inventoryService: InventoryService
  ) {}

  async start(): Promise {
    const consumer = this.kafka.consumer({ groupId: "inventory-service" });
    await consumer.connect();
    await consumer.subscribe({ topic: "orders.placed" });

    await consumer.run({
      eachMessage: async ({ message }) => {
        const event = JSON.parse(message.value!.toString());
        if (event.eventType === "OrderPlaced") {
          await this.inventoryService.reserveItems(event.orderId, event.items);
        }
      },
    });
  }
}

Data Management

Each microservice should own its data. This means no shared databases. This principle, called Database per Service, ensures services are truly independent and can evolve their data schema without impacting others. The trade-off is that you must handle data consistency across services.

Data Consistency Strategies

  • Saga Pattern: Coordinate transactions across services using a sequence of local transactions with compensating actions
  • Event Sourcing: Store the sequence of events rather than current state — rebuild state by replaying events
  • CQRS: Separate read and write models — eventually consistent read views updated by events
  • Outbox Pattern: Write events to an outbox table in the same transaction as the data change, then publish asynchronously

Service Discovery and Load Balancing

// Simple service registry pattern
interface ServiceInstance {
  id: string;
  name: string;
  host: string;
  port: number;
  healthCheck: string;
  metadata: Record;
}

class ServiceRegistry {
  private services = new Map();

  register(instance: ServiceInstance): void {
    const instances = this.services.get(instance.name) || [];
    instances.push(instance);
    this.services.set(instance.name, instances);
  }

  deregister(serviceName: string, instanceId: string): void {
    const instances = this.services.get(serviceName) || [];
    this.services.set(
      serviceName,
      instances.filter(i => i.id !== instanceId)
    );
  }

  discover(serviceName: string): ServiceInstance | null {
    const instances = this.services.get(serviceName) || [];
    if (instances.length === 0) return null;
    // Simple round-robin load balancing
    const index = Math.floor(Math.random() * instances.length);
    return instances[index];
  }
}

Observability

In a microservices system, observability is essential. You need three pillars: logs (what happened), metrics (how the system is performing), and traces (how a request flows through services). Without these, debugging distributed systems becomes nearly impossible.

// Distributed tracing middleware
import { v4 as uuidv4 } from "uuid";

interface TraceContext {
  traceId: string;
  spanId: string;
  parentSpanId?: string;
}

function tracingMiddleware(req: Request, res: Response, next: NextFunction) {
  const traceId = req.headers["x-trace-id"] as string || uuidv4();
  const parentSpanId = req.headers["x-span-id"] as string;
  const spanId = uuidv4();

  req.traceContext = { traceId, spanId, parentSpanId };

  // Propagate trace context in response headers
  res.setHeader("x-trace-id", traceId);
  res.setHeader("x-span-id", spanId);

  // Log with trace context
  console.log(JSON.stringify({
    level: "info",
    traceId,
    spanId,
    parentSpanId,
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString(),
  }));

  next();
}

Microservices Checklist

  • Health checks: Every service must expose a health endpoint for orchestrators
  • Centralized logging: Use structured logging with correlation IDs (ELK stack, Datadog)
  • Circuit breakers: Prevent cascading failures when downstream services are unavailable
  • API versioning: Plan for backward-compatible changes from day one
  • Contract testing: Verify service interfaces with consumer-driven contract tests

Continue Learning