From CRUD to Systems: How Applications Become Complex
Understanding the evolution from simple CRUD applications to complex distributed systems, and the architectural decisions that shape this journey.
Every software system starts simple. A basic CRUD application with a few tables, some endpoints, and a frontend. Then the business grows. Requirements accumulate. The simple application becomes a complex distributed system.
This article traces that evolution and the architectural decisions at each inflection point.
Phase 1: The Monolithic CRUD Application
Characteristics:
- Single codebase
- One database
- Synchronous request/response
- Simple deployment
# app/main.py
@app.post("/users")
def create_user(user: UserCreate):
db_user = User(**user.dict())
db.add(db_user)
db.commit()
return db_user
@app.get("/users/{user_id}")
def get_user(user_id: int):
return db.query(User).filter(User.id == user_id).first()
When this works: Small teams, simple domains, low traffic, rapid iteration.
Breaking points:
- Team grows beyond 8-10 engineers
- Deployment becomes risky (everything deploys together)
- Database becomes a bottleneck
- Different parts need different scaling
Phase 2: Modular Monolith
Strategy: Keep single deployment, enforce internal boundaries.
# Structure
app/
├── users/
│ ├── models.py
│ ├── service.py
│ ├── api.py
│ └── events.py
├── orders/
│ ├── models.py
│ ├── service.py
│ ├── api.py
│ └── events.py
└── shared/
├── database.py
├── events.py
└── exceptions.py
Key principle: Modules communicate through explicit interfaces, not direct imports.
# users/service.py
class UserService:
def __init__(self, event_bus: EventBus):
self.event_bus = event_bus
def create_user(self, data: UserCreate) -> User:
user = User(**data.dict())
self.db.add(user)
self.db.commit()
# Publish event instead of calling other modules directly
self.event_bus.publish(UserCreated(user_id=user.id, email=user.email))
return user
Benefits:
- Clear ownership boundaries
- Easier to extract services later
- Single deployment still simple
- Testable in isolation
Phase 3: Service Extraction
When to extract:
- Different scaling requirements
- Different deployment cadences
- Team autonomy needs
- Technology diversity
First service to extract: Usually the one with:
- Highest throughput
- Most distinct domain
- Clearest boundaries
# Before: Direct call
def create_order(user_id: int, items: list[OrderItem]):
user = user_service.get_user(user_id) # Cross-module call
# ...
# After: Event-driven
def create_order(user_id: int, items: list[OrderItem]):
# Validate locally, publish event
event_bus.publish(OrderCreated(
order_id=order.id,
user_id=user_id,
items=items
))
Communication patterns:
| Pattern | Use Case | Complexity | |---------|----------|------------| | Sync HTTP/gRPC | Queries, real-time needs | Low | | Async Events | Commands, notifications | Medium | | Saga/Orchestration | Distributed transactions | High |
Phase 4: Distributed System Concerns
Once you have multiple services, new challenges emerge:
Service Discovery
# Consul/etcd registration
service:
name: "order-service"
port: 8080
health_check: "/health"
tags: ["v1", "payments"]
Circuit Breakers
@circuit_breaker(failure_threshold=5, recovery_timeout=30)
async def call_payment_service(request: PaymentRequest):
return await payment_client.charge(request)
Distributed Tracing
# Every request gets a trace ID
@app.middleware("http")
async def add_trace_id(request: Request, call_next):
trace_id = request.headers.get("X-Trace-ID", generate_trace_id())
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["X-Trace-ID"] = trace_id
return response
Idempotency
@app.post("/orders")
async def create_order(
request: OrderRequest,
idempotency_key: str = Header(...)
):
# Check if already processed
existing = await redis.get(f"idempotency:{idempotency_key}")
if existing:
return json.loads(existing)
order = await order_service.create(request)
# Store result
await redis.setex(f"idempotency:{idempotency_key}", 86400, json.dumps(order))
return order
Phase 5: Platform Engineering
At scale, you build platforms, not just services:
Internal Developer Platform
# Developer self-service
apiVersion: platform.company.com/v1
kind: Service
metadata:
name: "notification-service"
spec:
language: "python"
framework: "fastapi"
resources:
cpu: "500m"
memory: "512Mi"
autoscaling:
minReplicas: 2
maxReplicas: 20
dependencies:
- "postgresql"
- "redis"
- "kafka"
Observability Stack
┌─────────────────────────────────────┐
│ Metrics (Prometheus) │
├─────────────────────────────────────┤
│ Logs (Loki/ELK) │
├─────────────────────────────────────┤
│ Traces (Jaeger/Zipkin) │
├─────────────────────────────────────┤
│ Profiles (Pyroscope) │
└─────────────────────────────────────┘
Decision Framework
When facing architectural decisions, use this framework:
1. Reversibility
- Reversible (feature flags, config changes): Decide quickly, iterate
- Irreversible (database schema, service boundaries): Invest in analysis
2. Cost of Delay
- What happens if we wait?
- What happens if we're wrong?
3. Team Topology
- Does this match team structure?
- Will it enable or hinder autonomy?
4. Operational Maturity
- Can we operate this in production?
- Do we have the tooling?
Common Anti-Patterns
Distributed Monolith
Services that must deploy together, share databases, or have synchronous call chains.
Premature Extraction
Breaking apart before you have clear domain boundaries or operational capacity.
Chatty Services
Services making dozens of synchronous calls to fulfill a single request.
Shared Database
Multiple services reading/writing the same tables.
Evolution, Not Revolution
The path from CRUD to complex system isn't a single decision—it's a series of incremental choices:
- Start simple — Monolith with clean module boundaries
- Extract when painful — Not before
- Invest in platform — When you have 3+ services
- Standardize communication — Events for commands, APIs for queries
- Build observability first — You can't operate what you can't see
The best architecture is the one that solves your current problems while keeping future options open.
Related reading: Where Should Business Logic Actually Live? and Finding the Real Bottleneck in Slow PostgreSQL Queries.