Building Microservices Architecture
At some point every growing app hits the same wall. A one-line fix to the checkout flow means redeploying the whole thing, a slow reporting query drags down the login page, and three teams keep stepping on each other's migrations. Splitting the app into separate services sounds like the fix, and sometimes it is. Other times you end up with a distributed monolith that's harder to run than what you started with.
This post walks through the parts that decide which way it goes: where to draw service boundaries, how services talk to each other, how to keep data consistent without shared transactions, and what you need in place (gateway, discovery, resilience, tracing, Kubernetes) before running this in production. The examples are in Node.js, but the patterns carry over to Laravel or anything else.
- Core principles and characteristics of microservices
- Service decomposition strategies and domain-driven design
- Inter-service communication patterns (sync vs async)
- Data management and the database-per-service pattern
- API Gateway, service discovery, and load balancing
- Deployment, monitoring, and tracing requests across services
What Are Microservices?
Strip away the buzzwords and you get an app built from small services that each run as their own process and talk over the network. For that split to actually pay off, each service should be:
Independently Deployable
Deploy, update, and scale each service without affecting others
Single Responsibility
Each service focuses on one specific business capability
Own Its Data
Services manage their own database and data model
Technology Agnostic
Pick the stack that fits each service (though fewer stacks is usually easier to maintain)
Microservices vs Monolithic Architecture
Neither column is "better". Each side of this table trades one kind of pain for another:
| Aspect | Monolithic | Microservices |
|---|---|---|
| Deployment | Deploy entire application | Deploy individual services |
| Scaling | Scale entire application | Scale specific services |
| Technology | Single tech stack | Multiple tech stacks |
| Team Structure | Large, centralized teams | Small, autonomous teams |
| Complexity | Simpler initially | Higher operational complexity |
| Failure Impact | One bad bug can take down everything | Isolated, if you build for it |
Microservices Architecture Overview
Most setups end up with the same layers: clients, a gateway in front, the services themselves, a message broker for async work, and one database per service.
A typical microservices architecture with API Gateway, multiple services, message broker, and database per service
Service Decomposition Strategies
Getting the boundaries wrong is the most expensive mistake here, because moving them later means migrating data between databases. Two approaches work well in practice.
1. Decompose by Business Capability
Split along what the business does, not how the code happens to be organized. "Billing" and "shipping" stay around for years, while your current controller layout probably won't, so services built around capabilities tend to stay stable.
┌─────────────────────────────────────────────────────────────┐
│ E-Commerce Platform │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Customer │ │ Catalog │ │ Inventory │ │
│ │ Management │ │ Management │ │ Management │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Order │ │ Payment │ │ Shipping │ │
│ │ Processing │ │ Processing │ │ Delivery │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
2. Decompose by Subdomain (Domain-Driven Design)
Domain-Driven Design (DDD) gives you a vocabulary for finding boundaries. A good hint: if "Customer" means something different to the sales team than to the support team, you're probably looking at two bounded contexts, and each one is a candidate service.
- Bounded Context: A logical boundary within which a domain model is defined
- Aggregate: A cluster of domain objects treated as a single unit
- Entity: An object defined by its identity rather than attributes
- Value Object: An object defined by its attributes, immutable
// Order Aggregate Root
class Order {
constructor(id, customerId, items = []) {
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = 'PENDING';
this.totalAmount = this.calculateTotal();
this.createdAt = new Date();
}
// Business logic belongs in the aggregate
addItem(productId, quantity, price) {
const existingItem = this.items.find(i => i.productId === productId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push(new OrderItem(productId, quantity, price));
}
this.totalAmount = this.calculateTotal();
return this;
}
removeItem(productId) {
this.items = this.items.filter(i => i.productId !== productId);
this.totalAmount = this.calculateTotal();
return this;
}
confirm() {
if (this.items.length === 0) {
throw new Error('Cannot confirm empty order');
}
this.status = 'CONFIRMED';
this.confirmedAt = new Date();
return this;
}
calculateTotal() {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
}
}
// Value Object
class OrderItem {
constructor(productId, quantity, price) {
this.productId = productId;
this.quantity = quantity;
this.price = price;
}
}
module.exports = { Order, OrderItem };
Inter-Service Communication
Once the code lives in separate processes, every function call that crossed a boundary becomes a network call. You've got two options: synchronous requests or asynchronous events.
REST API Communication
Services call each other directly over HTTP. Easy to write and easy to follow, but if the User Service is down, so is order creation.
// Order Service calling User Service via REST
const axios = require('axios');
class OrderService {
async createOrder(userId, items) {
// Synchronous call to User Service
const userResponse = await axios.get(
`http://user-service:3001/api/users/${userId}`
);
if (!userResponse.data.active) {
throw new Error('User account is inactive');
}
// Synchronous call to Inventory Service
for (const item of items) {
const inventoryResponse = await axios.post(
'http://inventory-service:3002/api/inventory/reserve',
{ productId: item.productId, quantity: item.quantity }
);
if (!inventoryResponse.data.success) {
throw new Error(`Insufficient inventory for ${item.productId}`);
}
}
// Create the order
const order = await Order.create({
userId,
items,
status: 'PENDING'
});
return order;
}
}
Event-Driven Communication
Services publish events to a broker and don't wait for anyone. If a consumer is down, messages queue up until it comes back. The cost is that you lose the immediate answer.
// Order Service publishing events
const amqp = require('amqplib');
class OrderEventPublisher {
async connect() {
this.connection = await amqp.connect('amqp://rabbitmq:5672');
this.channel = await this.connection.createChannel();
await this.channel.assertExchange('orders', 'topic', { durable: true });
}
async publishOrderCreated(order) {
const event = {
type: 'ORDER_CREATED',
timestamp: new Date().toISOString(),
data: {
orderId: order.id,
userId: order.userId,
items: order.items,
totalAmount: order.totalAmount
}
};
this.channel.publish(
'orders',
'order.created',
Buffer.from(JSON.stringify(event))
);
console.log(`Published ORDER_CREATED event for order ${order.id}`);
}
}
// Notification Service subscribing to events
class NotificationEventHandler {
async start() {
const connection = await amqp.connect('amqp://rabbitmq:5672');
const channel = await connection.createChannel();
await channel.assertExchange('orders', 'topic', { durable: true });
const queue = await channel.assertQueue('notifications.orders', { durable: true });
await channel.bindQueue(queue.queue, 'orders', 'order.*');
channel.consume(queue.queue, async (msg) => {
const event = JSON.parse(msg.content.toString());
switch (event.type) {
case 'ORDER_CREATED':
await this.sendOrderConfirmation(event.data);
break;
case 'ORDER_SHIPPED':
await this.sendShippingNotification(event.data);
break;
}
channel.ack(msg);
});
}
async sendOrderConfirmation(orderData) {
// Send email, SMS, push notification, etc.
console.log(`Sending confirmation for order ${orderData.orderId}`);
}
}
Communication Patterns Comparison
- axios has no timeout by default (
timeout: 0). If the Inventory Service hangs instead of failing, the Order Service request hangs with it, and so does every worker waiting on it. Always set an explicit timeout on service-to-service calls. - Unacked RabbitMQ messages pile up. If
sendOrderConfirmationthrows,channel.ack(msg)never runs and the message sits unacknowledged until the channel closes. Wrap the handler in try/catch,nackon failure (ideally to a dead letter queue), and setchannel.prefetch(n)so one consumer doesn't pull the whole queue into memory.
API Gateway Pattern
You don't want mobile apps knowing the internal hostname of every service, and you don't want each service re-implementing JWT checks. The gateway sits in front, gives clients one entry point, and handles authentication, rate limiting, and routing in one place.
Request Routing
Routes incoming requests to the appropriate microservice based on the URL path or headers.
Authentication & Authorization
Validates JWT tokens or API keys before forwarding requests to backend services.
Rate Limiting
Protects services from abuse by limiting the number of requests per client.
Response Aggregation
Combines responses from multiple services into a single response for the client.
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const app = express();
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: { error: 'Too many requests, please try again later' }
});
app.use(limiter);
// JWT Authentication middleware
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid token' });
}
};
// Service routes configuration
const services = {
users: {
target: 'http://user-service:3001',
pathRewrite: { '^/api/users': '/users' }
},
orders: {
target: 'http://order-service:3002',
pathRewrite: { '^/api/orders': '/orders' }
},
products: {
target: 'http://product-service:3003',
pathRewrite: { '^/api/products': '/products' }
},
payments: {
target: 'http://payment-service:3004',
pathRewrite: { '^/api/payments': '/payments' }
}
};
// Create proxy middleware for each service
Object.entries(services).forEach(([name, config]) => {
app.use(
`/api/${name}`,
authenticate, // Require auth for all service routes
createProxyMiddleware({
target: config.target,
changeOrigin: true,
pathRewrite: config.pathRewrite,
onProxyReq: (proxyReq, req) => {
// Forward user info to backend services
proxyReq.setHeader('X-User-Id', req.user.id);
proxyReq.setHeader('X-User-Role', req.user.role);
},
onError: (err, req, res) => {
console.error(`Proxy error for ${name}:`, err);
res.status(503).json({
error: 'Service temporarily unavailable',
service: name
});
}
})
);
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Response aggregation example
app.get('/api/dashboard', authenticate, async (req, res) => {
try {
const [userProfile, recentOrders, notifications] = await Promise.all([
fetch(`http://user-service:3001/users/${req.user.id}`).then(r => r.json()),
fetch(`http://order-service:3002/orders?userId=${req.user.id}&limit=5`).then(r => r.json()),
fetch(`http://notification-service:3005/notifications?userId=${req.user.id}`).then(r => r.json())
]);
res.json({
user: userProfile,
recentOrders,
notifications
});
} catch (error) {
res.status(500).json({ error: 'Failed to aggregate dashboard data' });
}
});
app.listen(3000, () => {
console.log('API Gateway running on port 3000');
});
Data Management Strategies
If two services read and write the same tables, they aren't really separate: a schema change in one breaks the other. So each service owns its data (Database per Service), and nobody else touches those tables directly. That keeps services decoupled, but it makes consistency your problem.
In a monolith, you wrap "create order, reserve stock, charge card" in one ACID transaction. Once those live in three databases, there's no single transaction to roll back, so you need a different approach.
Saga Pattern for Distributed Transactions
A saga breaks the big transaction into a chain of local ones. Each step commits in its own service and triggers the next. If a step fails, the saga runs compensating actions to undo what already happened.
The Saga pattern with compensating transactions for rollback on failure
const { Saga, SagaBuilder } = require('./saga-framework');
class CreateOrderSaga {
constructor(orderRepository, eventBus) {
this.orderRepository = orderRepository;
this.eventBus = eventBus;
}
build(orderData) {
return new SagaBuilder()
// Step 1: Create order in PENDING state
.step('createOrder')
.invoke(async (context) => {
const order = await this.orderRepository.create({
...orderData,
status: 'PENDING'
});
context.orderId = order.id;
return order;
})
.withCompensation(async (context) => {
await this.orderRepository.updateStatus(context.orderId, 'CANCELLED');
})
// Step 2: Reserve inventory
.step('reserveInventory')
.invoke(async (context) => {
const response = await this.eventBus.requestReply(
'inventory.reserve',
{
orderId: context.orderId,
items: orderData.items
},
{ timeout: 30000 }
);
if (!response.success) {
throw new Error('Inventory reservation failed');
}
context.reservationId = response.reservationId;
return response;
})
.withCompensation(async (context) => {
await this.eventBus.publish('inventory.release', {
reservationId: context.reservationId
});
})
// Step 3: Process payment
.step('processPayment')
.invoke(async (context) => {
const response = await this.eventBus.requestReply(
'payment.process',
{
orderId: context.orderId,
amount: orderData.totalAmount,
customerId: orderData.customerId
},
{ timeout: 60000 }
);
if (!response.success) {
throw new Error(`Payment failed: ${response.error}`);
}
context.paymentId = response.paymentId;
return response;
})
.withCompensation(async (context) => {
await this.eventBus.publish('payment.refund', {
paymentId: context.paymentId
});
})
// Step 4: Confirm order
.step('confirmOrder')
.invoke(async (context) => {
await this.orderRepository.updateStatus(context.orderId, 'CONFIRMED');
// Publish success event
await this.eventBus.publish('order.confirmed', {
orderId: context.orderId,
customerId: orderData.customerId
});
return { orderId: context.orderId, status: 'CONFIRMED' };
})
.build();
}
async execute(orderData) {
const saga = this.build(orderData);
try {
const result = await saga.execute();
console.log(`Order saga completed successfully:`, result);
return result;
} catch (error) {
console.error(`Order saga failed:`, error);
throw error;
}
}
}
module.exports = CreateOrderSaga;
Service Discovery
Containers come and go, and their IPs change every time. Hardcoding addresses stops working fast, so something has to keep track of which instances are alive and where they are.
Client-Side Discovery
Client queries the service registry and selects an instance. Example: Netflix Eureka with Ribbon.
Server-Side Discovery
Load balancer queries the registry and forwards requests. Example: AWS ALB, Kubernetes Services.
If you're on Kubernetes, its built-in Services and DNS already cover this, and you probably don't need Consul. The Compose example below is for setups without an orchestrator.
version: '3.8'
services:
consul:
image: consul:1.15
ports:
- "8500:8500"
command: agent -server -bootstrap-expect=1 -ui -client=0.0.0.0
user-service:
build: ./user-service
environment:
- SERVICE_NAME=user-service
- CONSUL_HOST=consul
depends_on:
- consul
deploy:
replicas: 3
order-service:
build: ./order-service
environment:
- SERVICE_NAME=order-service
- CONSUL_HOST=consul
depends_on:
- consul
deploy:
replicas: 2
api-gateway:
build: ./api-gateway
ports:
- "3000:3000"
environment:
- CONSUL_HOST=consul
depends_on:
- consul
- user-service
- order-service
Resilience Patterns
With enough services and network hops, something is always slow or down. These three patterns keep one bad service from dragging the rest along with it.
Circuit Breaker Pattern
Prevents cascading failures by stopping requests to a failing service. After a timeout, it allows test requests to check if the service has recovered.
const CircuitBreaker = require('opossum');
const options = {
timeout: 3000, // If function takes longer, trigger failure
errorThresholdPercentage: 50, // Open circuit at 50% failure rate
resetTimeout: 30000 // After 30s, try again
};
const breaker = new CircuitBreaker(callExternalService, options);
breaker.on('open', () => console.log('Circuit opened!'));
breaker.on('halfOpen', () => console.log('Circuit half-open, testing...'));
breaker.on('close', () => console.log('Circuit closed, back to normal'));
// Use the circuit breaker
async function getUserData(userId) {
try {
return await breaker.fire(userId);
} catch (error) {
// Return cached data or default response
return getCachedUser(userId) || { id: userId, name: 'Unknown' };
}
}
Retry with Exponential Backoff
Retries failed requests with growing delays between attempts. The random jitter matters: without it, every client retries at the same moment and hits the recovering service all at once. Only retry idempotent calls, or you'll charge someone twice.
async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
const delay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay + jitter}ms`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}
// Usage
const result = await retryWithBackoff(
() => axios.get('http://order-service/orders/123'),
3,
1000
);
Bulkhead Pattern
Isolates components to prevent failures from spreading. Limits concurrent calls to a service.
const Bottleneck = require('bottleneck');
// Create separate limiters for different services
const userServiceLimiter = new Bottleneck({
maxConcurrent: 10, // Max 10 concurrent requests
minTime: 100 // Min 100ms between requests
});
const orderServiceLimiter = new Bottleneck({
maxConcurrent: 20,
minTime: 50
});
// Wrap service calls with limiters
async function getUser(userId) {
return userServiceLimiter.schedule(() =>
axios.get(`http://user-service/users/${userId}`)
);
}
async function getOrders(userId) {
return orderServiceLimiter.schedule(() =>
axios.get(`http://order-service/orders?userId=${userId}`)
);
}
Observability: Logs, Metrics, Traces
In a monolith, a stack trace tells you where things broke. Across a dozen services, a slow request might touch five of them, and the log lines are spread over five places. You need logging, metrics, and distributed tracing, all sharing a correlation or trace ID so you can follow one request end to end.
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
// Initialize tracing
const provider = new NodeTracerProvider();
provider.addSpanProcessor(
new SimpleSpanProcessor(
new JaegerExporter({
endpoint: 'http://jaeger:14268/api/traces'
})
)
);
provider.register();
registerInstrumentations({
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation()
]
});
// Custom span for business logic
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');
async function processOrder(orderData) {
return tracer.startActiveSpan('processOrder', async (span) => {
try {
span.setAttribute('order.customer_id', orderData.customerId);
span.setAttribute('order.items_count', orderData.items.length);
// Validate order
const validationSpan = tracer.startSpan('validateOrder');
await validateOrder(orderData);
validationSpan.end();
// Save to database
const dbSpan = tracer.startSpan('saveToDatabase');
const order = await orderRepository.save(orderData);
dbSpan.setAttribute('order.id', order.id);
dbSpan.end();
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
Deployment with Kubernetes
Most teams running more than a handful of services end up on Kubernetes. It restarts crashed containers, scales replicas, and gives you service discovery for free. The manifest below covers a Deployment, a Service, and an autoscaler. Note the two separate probes: liveness restarts a stuck pod, while readiness just pulls it out of the load balancer until it's ready.
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3002"
spec:
containers:
- name: order-service
image: myregistry/order-service:v1.2.0
ports:
- containerPort: 3002
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: order-db-secret
key: url
- name: RABBITMQ_URL
value: "amqp://rabbitmq:5672"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 3002
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3002
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 3002
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Mistakes I See Most Often
Where Microservice Projects Go Wrong
- Splitting before the boundaries are clear. If you don't know the domain yet, you'll guess wrong, and fixing a bad boundary between two databases is far harder than moving a class between folders. A modular monolith first, then extracting services, is usually the safer path.
- Chains of synchronous calls. If A calls B calls C calls D over HTTP, your availability is roughly the product of all four, and latency adds up. That's a distributed monolith. Push anything that doesn't need an immediate answer onto events.
- A shared database "just for now". It never stays temporary. Once two services read the same table, you can't change that schema without coordinating deploys.
- No timeouts, no idempotency. Retries without idempotency keys turn one failed payment call into two charges. Timeouts should be shorter the further down the call chain you go.
- Adding tracing after the first outage. Correlation IDs and tracing are cheap on day one and painful to retrofit across services owned by different people.
- Breaking API changes without versioning. Services deploy independently, so old and new versions will run side by side. Add fields; don't rename or remove them without a version bump.
For a lot of projects, a well-structured monolith is the better call. I'd stay with one if:
- The team is small enough that everyone works on the same codebase without blocking each other (roughly under 10 developers)
- You're building an MVP and the product will change shape a few times
- Domain boundaries are still fuzzy
- Nobody on the team owns CI/CD, monitoring, and on-call for a dozen deployables
Where they do earn their keep: several teams shipping on different schedules, parts of the system with very different scaling needs (a video encoder vs. a settings page), or a component that needs to stay up when the rest is being deployed.