Microservices #
Microservices is an architectural approach where an application is built as a collection of small independent services — each running in its own process, communicating through well-defined APIs, and deployable separately. Each service is responsible for one specific business domain.
What’s often not realized: microservices aren’t about size (not “small” in terms of lines of code), but about independence. Services that can be developed, tested, deployed, and scaled independently of other services — that’s what makes this architecture valuable.
And what’s even less often realized: microservices bring very significant complexity. Teams not ready to face distributed systems will find themselves with all the weaknesses of monoliths plus all the weaknesses of distributed systems — without the benefits of either. Martin Fowler calls this the “distributed monolith” — the worst anti-pattern of both worlds.
Monoliths First, Microservices Later #
One of the most common mistakes in software engineering is starting directly with microservices. It feels like an ambitious, forward-thinking decision, but it almost always leads to problems.
Why starting with a monolith is wiser:
Project beginnings:
→ Business domains aren't fully understood yet
→ Wrong boundaries are very expensive to change in microservices
→ Small teams don't need the deployment independence microservices claim
→ Operational overhead doesn't justify the benefits
Good monoliths (modular monoliths):
→ Code structured with clear module boundaries
→ Modules only communicate through defined interfaces
→ Easy to refactor into microservices later
→ Far easier to develop, test, and debug
When microservices start making sense:
→ Teams are large enough (usually 15+ engineers)
→ Clear scaling bottlenecks specific to domains
→ High deployment frequency where different teams need independent deployments
→ Business domains well understood (not assumed)
→ Teams experienced with distributed systems
graph LR
subgraph Monolith["Modular Monolith — Start here"]
A[User Module] --> B[Order Module]
A --> C[Payment Module]
B --> C
B --> D[Inventory Module]
end
subgraph Microservices["Microservices — Evolve when clear needs exist"]
E[User Service] -->|API| F[Order Service]
F -->|API| G[Payment Service]
F -->|API| H[Inventory Service]
E -->|API| G
end
Monolith -->|Evolve when real needs appear| MicroservicesDefining Correct Service Boundaries #
Wrong service boundaries are the root of most problems in microservice architectures. Overly granular boundaries create chatty, interdependent services. Overly wide boundaries lose the independence benefits.
Domain-Driven Design (DDD) provides a framework for defining correct boundaries through the concept of Bounded Contexts — each service should correspond to one bounded context, where the domain model has consistent, unambiguous meaning.
Service boundary definition principles:
✓ Aligned with business domains, not technical layers
Don't: UserDatabase Service, OrderRepository Service
Do: User Service (everything about users), Order Service (everything about orders)
✓ High cohesion, loose coupling
High cohesion: everything in this service is closely related
Loose coupling: services don't need to know other services' implementation details
✓ Services own their own databases (database per service)
No database sharing between services
This is what enables true deployment independence
✓ Services deployable without changing other services
If changing one service always requires changing another → wrong boundary
✗ Signs of wrong boundaries:
→ Service A is always called together with Service B (should be one service)
→ Changing Service A's domain model always requires changing Service B
→ One user request needs 10+ service calls to complete (too granular)
→ Services without their own state (only orchestrators without domain logic)
Inter-Service Communication: Synchronous vs Asynchronous #
The choice of how services communicate with each other is one of the most important decisions in microservices.
Synchronous Communication (REST/gRPC) #
// Service A calls Service B synchronously.
// HTTP client with timeouts and retries.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// getUserProfile calls the User Service, retrying with
// exponential backoff on failure.
func getUserProfile(ctx context.Context, userID int) (map[string]interface{}, error) {
client := &http.Client{Timeout: 5 * time.Second}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("http://user-service/users/%d", userID), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Request-ID", getRequestID()) // propagate trace context
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
// Exponential backoff: 1s, 2s, 4s (capped at 10s)
wait := time.Duration(1<<uint(attempt)) * time.Second
if wait > 10*time.Second {
wait = 10 * time.Second
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("getUserProfile failed after retries: %w", lastErr)
}
// getInventory adds a circuit breaker so a failing service
// doesn't cascade failures to the rest of the system.
func getInventory(ctx context.Context, productID int) (map[string]interface{}, error) {
cb := newCircuitBreaker(5, 30*time.Second) // pseudo circuit breaker
if !cb.allow() {
return nil, fmt.Errorf("circuit is OPEN")
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://inventory-service/products/%d/stock", productID))
if err != nil {
cb.failure()
return nil, err
}
defer resp.Body.Close()
cb.success()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
When to use synchronous communication:
✓ Responses needed before continuing (data queries)
✓ Users waiting for direct responses (read operations)
✓ Operations requiring immediate acknowledgements
Synchronous communication risks:
→ Temporal coupling: if Service B is down, Service A also fails
→ Cascade failures: one service's failure spreads to others
→ Latency accumulation: latency = the sum of all called services' latencies
→ Mandatory: circuit breakers + timeouts + retries with exponential backoff
Asynchronous Communication (Message Queues) #
// Communication via message queues — loose coupling, resilient.
package main
import (
"context"
"encoding/json"
"os"
)
// OrderCreatedEvent is published when an order is created.
type OrderCreatedEvent struct {
EventType string `json:"event_type"`
OrderID string `json:"order_id"`
UserID int `json:"user_id"`
TotalAmount float64 `json:"total_amount"`
Items []Item `json:"items"`
}
// PublishOrderCreated publishes an event to a queue — the Order
// Service doesn't need to know who will consume this event.
func PublishOrderCreated(orderID string, userID int, total float64, items []Item) error {
event := OrderCreatedEvent{
EventType: "order.created",
OrderID: orderID,
UserID: userID,
TotalAmount: total,
Items: items,
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
// aws-sdk-go-v2 style SQS call:
_, err = sqsClient.SendMessage(context.Background(), &sqsv2.SendMessageInput{
QueueUrl: aws.String(os.Getenv("ORDER_EVENTS_QUEUE_URL")),
MessageBody: aws.String(string(payload)),
})
return err
}
// HandleOrderCreated — the Inventory Service consumes the order.created
// event. If the Inventory Service is down, messages stay in the queue
// until processed.
func HandleOrderCreated(eventData map[string]interface{}) {
orderID := eventData["order_id"].(string)
items := eventData["items"].([]interface{})
for _, item := range items {
m := item.(map[string]interface{})
reserveInventory(m["product_id"].(string), m["quantity"].(int))
}
logger.Info("Inventory reserved for order %s", orderID)
}
When to use asynchronous communication:
✓ Operations not needing immediate responses (writes/actions)
✓ Fanout: one event consumed by many services
✓ Bufferable workloads (emails, notifications, report generation)
✓ Resilience: consumers can be down and catch up later
Useful event patterns:
Event Notification: "Something happened" (minimal data)
→ order.created { order_id: "123" }
→ Consumers fetch details themselves if needed
Event-Carried State Transfer: events contain all data
→ order.created { order_id: "123", user_id: 42, items: [...] }
→ Consumers don't need to call back to other services
Event Sourcing: all state changes as events
→ Rebuild state by replaying all events
→ More complex but very powerful for audit trails
API Gateways: The Single Entry Point #
An API Gateway is a component sitting in front of all microservices, providing a single entry point for clients.
API Gateway responsibilities:
Routing:
→ GET /users/* → User Service
→ GET /orders/* → Order Service
→ POST /payments/* → Payment Service
Cross-cutting concerns:
→ Authentication & Authorization (JWT validation before forwarding requests)
→ Rate limiting
→ SSL termination
→ Request/response logging
→ Correlation ID injection (for distributed tracing)
Client-specific aggregation:
→ BFF (Backend for Frontend): one gateway per client type
- Mobile BFFs: responses optimized for low bandwidth
- Web BFFs: richer responses for desktop browsers
Benefits:
→ Clients don't need to know how many services exist or where they are
→ Internal service changes don't affect exposed client APIs
→ A single place for cross-cutting concerns
# Kong API Gateway configuration (example)
services:
- name: user-service
url: http://user-service:8080
- name: order-service
url: http://order-service:8080
routes:
- name: users-route
service: user-service
paths:
- /api/users
- name: orders-route
service: order-service
paths:
- /api/orders
plugins:
- name: jwt # Authentication
service: user-service
config:
key_claim_name: kid
- name: rate-limiting
config:
minute: 100 # 100 requests per minute per consumer
policy: local
- name: correlation-id
config:
header_name: X-Correlation-ID
generator: uuid
Service Discovery #
In microservices, service instances can run on various hosts and ports that change over time (especially in Kubernetes). Service discovery lets services find each other without hardcoded addresses.
Two service discovery approaches:
Client-side discovery:
→ Clients query a Service Registry to find instances
→ Clients do their own load balancing
→ Examples: Netflix Eureka, Consul
Server-side discovery:
→ Clients only know one endpoint (a load balancer)
→ The load balancer knows where instances are
→ Examples: AWS ALB, Kubernetes Services
In Kubernetes: built-in service discovery
→ Every Service gets a DNS name
→ http://user-service.default.svc.cluster.local
→ Or simply: http://user-service (in the same namespace)
→ kube-proxy handles load balancing to healthy pods
# Kubernetes Service definition — automatic service discovery
apiVersion: v1
kind: Service
metadata:
name: user-service
namespace: default
spec:
selector:
app: user-service # route to pods with this label
ports:
- protocol: TCP
port: 80 # exposed port
targetPort: 8080 # port inside the pod
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3 # 3 instances for HA
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: myregistry/user-service:v1.2.3
ports:
- containerPort: 8080
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: user-service-secrets
key: database_url
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
Distributed Tracing #
When one user request passes through 5 services, debugging becomes very difficult without distributed tracing. Distributed tracing connects all logs and spans from all services into one end-to-end trace.
// OpenTelemetry — the standard for distributed tracing.
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/trace"
)
// setupTracing configures the tracer — done once at startup.
func setupTracing(serviceName string) error {
exporter, err := otlptracegrpc.New(context.Background(),
otlptracegrpc.WithEndpoint("jaeger:4317"))
if err != nil {
return err
}
provider := trace.NewTracerProvider(
trace.WithBatcher(exporter),
)
otel.SetTracerProvider(provider)
return nil
}
// tracer for manual instrumentation.
var tracer = otel.Tracer("orders")
// processOrder records a span for each step of the flow.
// All child spans connect to the existing parent span.
func processOrder(ctx context.Context, orderData map[string]interface{}) error {
ctx, span := tracer.Start(ctx, "process_order")
defer span.End()
span.SetAttributes(
attribute.String("order.id", orderData["id"].(string)),
attribute.Float64("order.total", orderData["total"].(float64)),
)
ctx, child := tracer.Start(ctx, "validate_payment")
if err := validatePayment(ctx, orderData); err != nil {
child.RecordError(err)
child.End()
return err
}
child.End()
ctx, child = tracer.Start(ctx, "reserve_inventory")
if err := reserveInventory(ctx, orderData["items"]); err != nil {
child.RecordError(err)
child.End()
return err
}
child.End()
_, child = tracer.Start(ctx, "notify_fulfillment")
if err := publishOrderCreated(ctx, orderData); err != nil {
child.RecordError(err)
child.End()
return err
}
child.End()
return nil
}
Distributed tracing provides:
Traces: the entire journey of one request end to end
Spans: one operation within a trace (one service call, one DB query)
Context propagation: trace IDs sent via HTTP headers to all services
What can be seen:
→ How much time is spent in each service
→ Where bottlenecks occur
→ Which service errors happen in
→ How services call each other
Tools:
→ Jaeger (open source)
→ Zipkin (open source)
→ AWS X-Ray
→ Datadog APM
Eventual Consistency: The Data Reality in Microservices #
Because each service owns its own database, no ACID transaction can span multiple services. Data between services can only achieve eventual consistency — consistent in the end, but with a window where states differ.
Managing eventual consistency:
The problem:
An order is created in the Order Service (status: "pending")
The order.created event is sent to the Payment Service
The Payment Service is down → not yet processed
The user sees the order status: "pending" — but payment hasn't been processed
→ Data temporarily inconsistent, but eventually consistent
Strategies:
1. The Saga Pattern — long-running transactions with compensating actions
Step 1: Order Service → create order (status: pending)
Step 2: Payment Service → charge payment
Step 3: Inventory Service → reserve items
Step 4: Order Service → update status to confirmed
If Step 3 fails:
Compensate Step 2: refund the payment
Compensate Step 1: cancel the order
2. The Outbox Pattern — avoiding the dual-write problem
Instead of writing to the DB and publishing events separately:
→ Write to the DB and the outbox table in one transaction
→ A background process publishes events from the outbox table
→ If publishing fails, the event stays in the outbox → retryable
// The Outbox Pattern for reliable event publishing.
package main
import (
"database/sql"
"encoding/json"
"log"
"time"
)
// OrderRepository creates orders and records the event
// in the same database transaction.
type OrderRepository struct {
db *sql.DB
}
// CreateOrderWithEvent creates the order AND records the event
// in one database transaction — no possibility of the order being
// created but the event not published.
func (r *OrderRepository) CreateOrderWithEvent(orderData map[string]interface{}) (int64, error) {
tx, err := r.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback() // no-op after Commit
// 1. Create the order
result, err := tx.Exec(
`INSERT INTO orders (user_id, total) VALUES (?, ?)`,
orderData["user_id"], orderData["total"])
if err != nil {
return 0, err
}
orderID, err := result.LastInsertId() // generate order.id
if err != nil {
return 0, err
}
// 2. Save the event to the outbox table in the same transaction
payload, err := json.Marshal(map[string]interface{}{
"order_id": orderID,
"user_id": orderData["user_id"],
"total": orderData["total"],
})
if err != nil {
return 0, err
}
if _, err := tx.Exec(
`INSERT INTO outbox (event_type, aggregate_id, payload, published)
VALUES ('order.created', ?, ?, false)`,
orderID, payload); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return orderID, nil
}
// OutboxPublisher runs as a separate background process,
// publishing events from the outbox table.
func (r *OrderRepository) OutboxPublisher() {
for {
rows, err := r.db.Query(
`SELECT id, event_type, payload FROM outbox
WHERE published = false ORDER BY created_at LIMIT 100`)
if err == nil {
for rows.Next() {
var (
id int64
eventType string
payload string
)
if err := rows.Scan(&id, &eventType, &payload); err != nil {
continue
}
if err := publishToQueue(eventType, payload); err == nil {
r.db.Exec(
`UPDATE outbox SET published = true, published_at = ? WHERE id = ?`,
time.Now(), id)
} else {
log.Printf("Failed to publish event %d: %v", id, err)
// published stays false → retried in the next iteration
}
}
rows.Close()
}
time.Sleep(5 * time.Second) // check every 5 seconds
}
}
When Microservices, When Monoliths #
A decision framework:
Questions to answer:
1. How large is the team?
< 10 engineers: monoliths are almost always better
10-30 engineers: modular monoliths, evaluate needs
30+ engineers: microservices start making sense
2. Are there scaling requirements differing per domain?
All domains grow proportionally → monoliths are fine
Search needs 10x more scale than checkout → microservices make sense
3. How mature is the business domain?
Just starting, lots of uncertainty → monoliths (easy to refactor)
Stable, well-understood domains → microservices are safer
4. Is the team experienced with distributed systems?
No → start with a monolith, learn distributed systems first
Yes → microservices can be considered
5. Is there an independent deployment need?
All features deployed together → monoliths are fine
Different teams need to release anytime → microservices
The common answer: start with a modular monolith.
Evolve to microservices based on real needs, not anticipation.
Anti-Patterns to Avoid #
✗ Anti-pattern 1: Distributed Monoliths
Tightly coupled interdependent services
Changing Service A always requires changing Services B and C
Deployments must happen together
→ All the bad of monoliths + all the bad of distributed systems
✓ Solution: fix boundaries, reduce inter-service coupling
✗ Anti-pattern 2: Too many services too early
"Nano-services": every function becomes its own service
Every request needs 15 service calls
Network overhead dominates latency
✓ Solution: start with larger services, split only when clear reasons exist
✗ Anti-pattern 3: Shared databases between services
The User Service and Order Service use the same database
No independence — schema changes in one place affect the other
✓ Solution: database per service, communication via APIs or events
✗ Anti-pattern 4: Long synchronous chains
Request → A → B → C → D → E → response
Accumulative latency + any failure fails everything
✓ Solution: redesign to async events, or shorten chains by merging services
✗ Anti-pattern 5: No distributed tracing
Bugs in production, no idea which services requests pass through or where they fail
Per-service logs aren't enough for distributed bug investigations
✓ Solution: OpenTelemetry + Jaeger/Zipkin from the start
✗ Anti-pattern 6: Ignoring eventual consistency
Assuming data is always consistent like in monoliths
Strange bugs because states are out of sync between services
✓ Solution: design for eventual consistency, use the Outbox Pattern
Microservices Checklist #
ARCHITECTURE:
□ Each service has a clear, cohesive business domain
□ Services deployable independently without coordinating with other services
□ Each service owns its own database (no sharing)
□ Service boundaries validated with event storming or domain analysis
COMMUNICATION:
□ Synchronous (REST/gRPC) only for needs requiring direct responses
□ Asynchronous (message queues) for operations not needing direct responses
□ Circuit breakers on all synchronous calls to other services
□ Timeouts configured for all external calls
□ Retries with exponential backoff for transient failures
RELIABILITY:
□ Health check endpoints in every service (/health or /ready)
□ Graceful shutdowns — finishing in-flight requests
□ Dead letter queues for failed messages
□ Outbox patterns for reliable event publishing
OBSERVABILITY:
□ Distributed tracing with OpenTelemetry
□ Correlation IDs propagated to all services
□ Structured logging with trace IDs in every log entry
□ Per-service metrics (request rates, error rates, latencies)
□ Centralized log aggregation (ELK, Loki)
API GATEWAYS:
□ Single entry points for all clients
□ Authentication/Authorization at the gateway layer
□ Rate limiting at gateways
□ Clear API versioning strategies
DEPLOYMENT:
□ Each service has its own CI/CD pipeline
□ Immutable container images (tagged with git SHAs)
□ Blue-green or canary deployments for zero downtime
□ Fast, tested rollbacks
DATA CONSISTENCY:
□ Eventual consistency understood and well-designed
□ Saga patterns for long-running transactions
□ Idempotent consumers for message processing
Summary #
- Start with monoliths, evolve to microservices — don’t start new projects with microservices. Modular monoliths are easier to develop, test, and debug, and easier to refactor into microservices when real needs emerge.
- Correct boundaries are the foundation of everything — wrong boundaries create distributed monoliths worse than either option. Use DDD Bounded Contexts as guidance.
- Database per service is mandatory — database sharing removes the independence that’s the main reason for microservices. Without it, all services stay tightly coupled.
- Choose communication per needs — synchronous for queries needing direct responses, asynchronous for bufferable actions. Don’t default to synchronous for everything.
- Circuit breakers prevent cascade failures — without circuit breakers, one service’s failure spreads across the entire system. This is the difference between partial and total outages.
- Distributed tracing is a need, not an option — debugging microservices without tracing is like debugging blind. Install OpenTelemetry from day one.
- Eventual consistency is a reality, not a bug — with database-per-service, there’s no strong consistency between services. Design systems to work with this, not against it.
- The Outbox Pattern prevents the dual-write problem — writing to the database and publishing events must happen in one atomic operation. The outbox pattern ensures events aren’t lost on failures.
- Operational complexity is much higher — more services mean more things that can fail, more to monitor, more deployments to coordinate. Make sure the team is ready.
- Microservices aren’t about technology — they’re about organization — Conway’s Law: system architecture mirrors organizational communication structures. Microservices work best when each service is owned by a dedicated team.
#
- Start with monoliths, evolve to microservices — don’t start new projects with microservices. Modular monoliths are easier to develop, test, and debug, and easier to refactor into microservices when real needs emerge.
- Correct boundaries are the foundation of everything — wrong boundaries create distributed monoliths worse than either option. Use DDD Bounded Contexts as guidance.
- Database per service is mandatory — database sharing removes the independence that’s the main reason for microservices. Without it, all services stay tightly coupled.
- Choose communication per needs — synchronous for queries needing direct responses, asynchronous for bufferable actions. Don’t default to synchronous for everything.
- Circuit breakers prevent cascade failures — without circuit breakers, one service’s failure spreads across the entire system. This is the difference between partial and total outages.
- Distributed tracing is a need, not an option — debugging microservices without tracing is like debugging blind. Install OpenTelemetry from day one.
- Eventual consistency is a reality, not a bug — with database-per-service, there’s no strong consistency between services. Design systems to work with this, not against it.
- The Outbox Pattern prevents the dual-write problem — writing to the database and publishing events must happen in one atomic operation. The outbox pattern ensures events aren’t lost on failures.
- Operational complexity is much higher — more services mean more things that can fail, more to monitor, more deployments to coordinate. Make sure the team is ready.
- Microservices aren’t about technology — they’re about organization — Conway’s Law: system architecture mirrors organizational communication structures. Microservices work best when each service is owned by a dedicated team.