Microservices vs Monolith: Architecture Decision Guide

Make the right architecture choice. Covers when to use monolith, microservices, or modular monolith with decision criteria, trade-offs, migration strategies, and team size considerations.

This guide helps teams decide between microservices and monolithic architecture when building software systems. It focuses on concrete architectural decisions, configuration options, and failure modes that arise in practice. The choice impacts deployment models, data consistency, service boundaries, and operational complexity.

When to Choose Microservices

Define Service Boundaries Based on Business Domains

Use bounded contexts from domain-driven design to define service boundaries:

-- Example: User management service schema
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Example: Order processing service schema
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    total DECIMAL(10,2),
    status VARCHAR(50)
);

Implement Service Communication Over HTTP

Use REST over HTTP for service-to-service communication:

# Example: Call another service using curl
curl -X GET \
  http://order-service:8080/api/v1/orders/123 \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
  -H 'Content-Type: application/json'

Configure Service Discovery

Set up service discovery using environment variables or configuration files:

# Example: Kubernetes service definition
apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-service
  ports:
  - port: 8080
    targetPort: 8080

Handle Distributed Transactions Carefully

Use eventual consistency patterns instead of distributed transactions:

// Example: Event-driven approach using message queues
func ProcessOrder(orderID string) error {
    // Publish order created event
    err := publishEvent("order.created", orderID)
    if err != nil {
        return fmt.Errorf("failed to publish event: %w", err)
    }
    
    // Update local state
    return updateOrderStatus(orderID, "processing")
}

When to Choose Monolith

Start with a Single Codebase for Simplicity

Use a single repository for all application logic:

# Example: Git repository structure
git clone https://example.com/myapp.git
cd myapp
ls -la
# Output:
# app/
#   main.go
#   handlers/
#     user.go
#     order.go
# config/
#   config.go

Implement Database Sharing Within Monolith

Use shared database for related functionality:

-- Example: Shared schema in monolith
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE user_profiles (
    user_id UUID REFERENCES users(id),
    bio TEXT,
    avatar_url VARCHAR(500)
);

Configure Monolith with Feature Flags

Use feature flags to toggle functionality without redeployment:

# Example: Feature flag configuration
features:
  new_ui: true
  experimental_api: false

Operational Considerations

Deployment Strategy Differences

Microservices require independent deployment:

# Example: Deploying a single service
kubectl set image deployment/user-service user-service=registry.example.com/user-service:v1.2.3

Monoliths can be deployed as a unit:

# Example: Deploying monolith
docker build -t myapp:latest .
docker run --rm -p 8080:8080 myapp:latest

Monitor Individual Services

Microservices require monitoring per service:

# Example: Check service health
curl http://user-service:8080/health
# Response:
# {"status":"healthy","timestamp":"2023-01-01T00:00:00Z"}

Monoliths are monitored as a single unit:

# Example: Check monolith health
curl http://myapp:8080/health
# Response:
# {"status":"healthy","timestamp":"2023-01-01T00:00:00Z"}

Handle Cross-Service Failures

Microservices need circuit breaker patterns:

// Example: Circuit breaker implementation
type CircuitBreaker struct {
    state       string
    failureRate float64
    failures    int
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    if cb.state == "open" {
        return fmt.Errorf("circuit breaker is open")
    }
    
    err := fn()
    if err != nil {
        cb.failures++
        if cb.failures > 5 {
            cb.state = "open"
        }
        return err
    }
    
    cb.failures = 0
    cb.state = "closed"
    return nil
}

Data Management Decisions

Choose Data Partitioning Strategy

For microservices, each service owns its data:

-- Example: User service database schema
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Example: Order service database schema
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    total DECIMAL(10,2),
    status VARCHAR(50)
);

For monoliths, shared database is acceptable:

-- Example: Shared schema in monolith
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    total DECIMAL(10,2),
    status VARCHAR(50)
);

Implement Data Synchronization

Use event sourcing or change data capture for consistency:

# Example: Kafka consumer setup
kafka-console-consumer.sh \
  --bootstrap-server kafka.example.com:9092 \
  --topic user-updated \
  --from-beginning

Communication Patterns

Choose Communication Style

Microservices use asynchronous communication:

// Example: Asynchronous message publishing
func SendNotification(userID string, message string) error {
    payload := map[string]interface{}{
        "user_id": userID,
        "message": message,
        "timestamp": time.Now(),
    }
    
    return publishToQueue("notifications", payload)
}

Monoliths use synchronous calls:

// Example: Synchronous internal call
func ProcessPayment(orderID string) error {
    user, err := GetUser(orderID)
    if err != nil {
        return fmt.Errorf("failed to get user: %w", err)
    }
    
    // Direct function call within monolith
    return chargeUser(user.ID, orderID)
}

Configuration and Environment Management

Manage Service Configurations

Microservices use environment variables:

# Example: Set service configuration
export DATABASE_URL="postgresql://user:pass@db:5432/myapp"
export SERVICE_PORT="8080"

Monoliths may use configuration files:

# Example: Configuration file for monolith
database:
  url: "postgresql://user:pass@db:5432/myapp"
  pool_size: 10
server:
  port: 8080

Common Failure Modes

Silent Data Inconsistency in Microservices

Services may fall out of sync without clear error messages:

# Example: Missing data consistency check
curl http://order-service/api/v1/orders/123
# Response:
# {"id":"123","status":"processing"}
# But user-service doesn't know about this order yet

Operational Complexity in Microservices

Multiple services increase deployment complexity:

# Example: Deployment failure due to missing dependency
kubectl apply -f service.yaml
# Error message:
# Error from server (BadRequest): error when creating "service.yaml":
#   Service "user-service" is not ready

Performance Overhead in Microservices

Network calls introduce latency:

# Example: High latency due to network calls
time curl http://order-service/api/v1/orders/123
# real 0m0.543s
# user 0m0.001s
# sys 0m0.002s

Monitoring Challenges

Microservices require distributed tracing:

# Example: Tracing request across services
curl -H "X-Trace-ID: abc123" http://frontend-service/api/v1/users/123
# Without proper tracing, this becomes opaque

Decision Framework

Evaluate Based on Team Size

Small teams (1-5 developers) often benefit from monoliths:

# Example: Monolith startup script
./start.sh
# Output:
# Starting monolith application...
# Listening on port 8080
# Database connected successfully

Large teams (>10 developers) may benefit from microservices:

# Example: Service composition
kubectl get pods -l app=user-service
# NAME                            READY   STATUS    RESTARTS   AGE
# user-service-7b5b6c9d8-fg4j5    1/1     Running   0          5m
# user-service-7b5b6c9d8-kj2h3    1/1     Running   0          5m

Consider Data Access Patterns

If services share data heavily, monolith may be better:

-- Example: Complex join across services
SELECT u.email, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';

If data is mostly isolated, microservices are preferred:

-- Example: Isolated service queries
SELECT * FROM users WHERE id = '123';
SELECT * FROM orders WHERE user_id = '123';

Plan for Maintenance Complexity

Microservices require more operational overhead:

# Example: Service health monitoring
kubectl get pods -A | grep -E "(user|order)-service"
# NAMESPACE   NAME                          READY   STATUS    RESTARTS   AGE
# default     user-service-7b5b6c9d8-fg4j5  1/1     Running   0          5m
# default     order-service-7b5b6c9d8-kj2h3 1/1     Running   0          5m

Handle Deployment Dependencies

Microservices may require careful dependency management:

# Example: Service readiness check
kubectl rollout status deployment/user-service
# Waiting for deployment "user-service" rollout to finish: 0 of 1 updated replicas are available...
# deployment "user-service" successfully rolled out

Key Decision Points

Communication Style

Choose synchronous communication for monoliths, asynchronous for microservices.

Data Management

Decide whether services should own their data or share databases.

Deployment Strategy

Consider how often and how you deploy changes.

Monitoring Requirements

Evaluate the complexity of monitoring distributed systems.

Team Structure

Match architecture to team size and organizational structure.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.