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 Contexts | Use DDD context maps to find natural boundaries | Complex domains with domain experts |
| Business Capabilities | Map services to what the business does (not how) | Well-understood business processes |
| Event Storming | Collaborative workshop to discover events and commands | New projects, team alignment |
| Team Ownership | Align 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