Skip to content

Microservices Platform: Deep Dive

This demo deploys five services into Kubernetes: a frontend, a backend API, a worker, a Redis queue, and a PostgreSQL database. Each runs in its own pod. Each can be scaled, updated, and restarted independently. That independence is the entire point of microservices.

This document explains the architecture principles behind that design, why certain trade-offs were made, and how Kubernetes provides the networking and scheduling primitives that make microservices practical.

What Makes This a Microservices Architecture

Section titled “What Makes This a Microservices Architecture”

A monolith bundles all functionality into a single deployable unit. A microservices architecture splits that functionality into small, independent services that communicate over the network. This demo has five:

ServiceRoleImage
FrontendServes the HTML dashboardnginx:1.25-alpine
BackendReturns JSON on API routesnginx:1.25-alpine
WorkerProcesses jobs from the queuebusybox:1.36
RedisQueue and cache layerredis:7-alpine
PostgreSQLPersistent data storepostgres:16-alpine

Each service has its own Deployment, its own container image, and its own Service resource. They share nothing except a namespace.

Bounded Contexts and Single Responsibility

Section titled “Bounded Contexts and Single Responsibility”

Two principles from Domain-Driven Design shape how you split a monolith.

Bounded contexts define clear boundaries around a business capability. The “order management” context owns everything about orders: the database schema, the API endpoints, the processing logic. In this demo, PostgreSQL holds the orders table, the backend serves the orders API, and the worker processes order-related jobs. They form a bounded context.

Single responsibility means each service does one thing. The frontend serves HTML. It does not query databases. The worker processes jobs. It does not serve HTTP traffic. The backend returns API responses. It does not render HTML pages.

Look at the backend deployment. It has environment variables for both PostgreSQL and Redis, but its nginx config only returns static JSON:

containers:
- name: backend
image: docker.io/library/nginx:1.25-alpine
env:
- name: POSTGRES_HOST
value: postgres
- name: POSTGRES_PORT
value: "5432"
- name: REDIS_HOST
value: redis
- name: REDIS_PORT
value: "6379"

The environment variables tell the backend where to find its dependencies. The backend knows about Redis and PostgreSQL. But the frontend knows about neither. That boundary matters.

In a monolith, one function calls another through a function call. In microservices, one service calls another through the network. Kubernetes makes this work with DNS-based service discovery.

Every Service object gets a DNS entry in the cluster. The Redis Service is defined like this:

apiVersion: v1
kind: Service
metadata:
name: redis
namespace: microservices-demo
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379

Because the Service is named redis in the microservices-demo namespace, any pod in that namespace can reach it at redis:6379. Pods in other namespaces can use redis.microservices-demo.svc.cluster.local:6379.

This is how the worker finds Redis:

env:
- name: REDIS_HOST
value: redis
- name: REDIS_PORT
value: "6379"

The worker does not need the pod’s IP address. It does not need to know which node Redis runs on. Kubernetes DNS resolves redis to the Service’s ClusterIP, and kube-proxy routes traffic to the actual Redis pod.

This is fundamentally different from hardcoding IP addresses. If the Redis pod restarts and gets a new IP, the Service continues to work. The DNS name stays the same.

Services can communicate synchronously or asynchronously. This demo uses both patterns.

The Ingress routes HTTP requests to the backend. A client sends a GET request to /api/orders. The Ingress forwards it to the backend Service. The backend returns JSON immediately. This is synchronous communication.

spec:
ingressClassName: nginx
rules:
- host: microservices.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 80

Synchronous communication is simple. The caller sends a request, waits for a response, and continues. It works well for read operations and queries.

The downside: if the backend is slow or down, the caller is blocked. Synchronous calls create temporal coupling between services.

The worker polls Redis for jobs. It does not receive HTTP requests. It pulls work from a queue at its own pace. This is asynchronous communication.

command:
- /bin/sh
- -c
- |
echo "Worker started, connecting to Redis at $REDIS_HOST:$REDIS_PORT"
while true; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Processing job from queue..."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Job processed successfully"
sleep 10
done

In a production system, the backend would push jobs into Redis (using LPUSH or a Streams command), and the worker would pull them (using BRPOP or XREAD). The queue decouples the producer from the consumer. If the worker is slow, jobs accumulate in Redis. If the worker crashes, jobs persist until it recovers.

Asynchronous communication is essential for work that takes time: sending emails, generating reports, resizing images, running ML inference. You do not want an HTTP request to wait for these operations.

This demo uses a single PostgreSQL instance. All services that need data share it. This is the “shared database” pattern.

containers:
- name: postgres
image: docker.io/library/postgres:16-alpine
env:
- name: POSTGRES_DB
value: ordersdb
- name: POSTGRES_USER
value: orders
- name: POSTGRES_PASSWORD
value: orders123

The shared database pattern is simpler but creates tight coupling. If the orders schema changes, every service that queries it must update. Migrations become coordination problems.

The alternative is database-per-service: each service owns its own database instance. The orders service owns ordersdb. The users service owns usersdb. Services never query each other’s databases directly. They communicate through APIs.

Trade-offs:

ApproachProsCons
Shared DBSimple, consistent, no duplicationTight coupling, single point of failure
DB per serviceLoose coupling, independent schemasData duplication, eventual consistency

For a small demo, the shared database is fine. For a system with 20 teams working on 50 services, database-per-service prevents one team’s schema change from breaking another team’s service.

External clients do not connect to services directly. They connect to the Ingress, which acts as a simple API gateway. The Ingress routes / to the frontend and /api to the backend:

paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80

Without an API gateway, clients would need to know the address of every service. Adding a new service means updating every client. The gateway provides a single entry point. Clients talk to one URL. The gateway routes to the correct service.

In production, the gateway also handles cross-cutting concerns: TLS termination, rate limiting, authentication, request logging. The API Gateway demo covers this in depth with Kong.

The worker is a Deployment with no Service. It does not receive inbound traffic. It exists solely to process background work.

Notice that the worker manifest has no Service object. Compare that to the backend, which has both a Deployment and a Service. The worker needs no Service because nothing sends HTTP requests to it. It pulls work from Redis.

This pattern appears everywhere in production systems:

  • Email senders that dequeue messages from a queue
  • Image processors that read from an object store
  • ML inference workers that consume prediction requests
  • Report generators triggered by scheduled jobs

Workers scale independently of the API layer. If the queue is backing up, you scale workers:

Terminal window
kubectl scale deployment worker --replicas=5 -n microservices-demo

The backend does not know or care how many workers exist. The queue absorbs the difference.

The backend exposes a /api/health endpoint:

location /api/health {
default_type application/json;
return 200 '{"status":"healthy","service":"backend-api","timestamp":"$time_iso8601"}';
}

Health endpoints serve two purposes. First, Kubernetes uses them for readiness and liveness probes. A liveness probe tells Kubernetes whether to restart the pod. A readiness probe tells Kubernetes whether to send traffic to it. This demo does not configure explicit probes, but production manifests should.

Second, health endpoints enable monitoring. An external system can poll /api/health and alert when it returns anything other than 200.

In a microservices architecture, health checking is more complex than in a monolith. The backend might be healthy, but if PostgreSQL is down, the backend cannot serve useful responses. A good health check distinguishes between “the process is running” (liveness) and “the service can handle requests” (readiness). Readiness checks should verify downstream dependencies.

The 12-factor app methodology defines principles for building cloud-native applications. This demo demonstrates several.

Factor III: Config. Configuration is injected via environment variables, not hardcoded:

env:
- name: POSTGRES_HOST
value: postgres
- name: POSTGRES_PORT
value: "5432"

The same container image can run in dev, staging, and production. Only the environment variables change.

Factor IV: Backing Services. PostgreSQL and Redis are “attached resources” accessed via environment variables. Swapping Redis for a managed Redis service means changing the REDIS_HOST value. The application code does not change.

Factor VI: Processes. Each service runs as a stateless process. The frontend and backend store no state in memory between requests. The worker is stateless too. State lives in Redis and PostgreSQL.

Factor VIII: Concurrency. Scaling happens by adding more processes (pods), not by threading within a single process. Need more backend capacity? Add replicas. Need more job throughput? Add workers.

Microservices introduce failure modes that do not exist in a monolith.

If PostgreSQL goes down, the backend cannot serve order data. If the backend goes down, the frontend links to /api/orders break. One failure cascades through dependent services.

In a monolith, a database failure crashes the entire application. In microservices, the blast radius depends on how services handle failures. A well-designed backend returns a cached response or a degraded response when PostgreSQL is unreachable. A poorly designed one returns a 500 error that confuses the frontend.

A circuit breaker stops calling a failing service. If the backend notices that PostgreSQL has failed five times in a row, it stops trying for 30 seconds. This prevents wasted resources and gives PostgreSQL time to recover.

This demo does not implement circuit breaking because it uses nginx for the backend (which returns static JSON). In a real system, libraries like Hystrix, resilience4j, or service mesh sidecars (Envoy, Istio) provide circuit breaking.

A microservices architecture can be partially available. The frontend can serve its HTML page even if the backend is down. The API endpoints on the page will fail, but the page itself loads. In a monolith, the entire application is either up or down.

Designing for partial availability means each service must handle the case where its dependencies are unreachable. Return cached data. Show a “service temporarily unavailable” message. Degrade gracefully.

Each Deployment can scale independently:

Terminal window
# The backend is overloaded. Scale it.
kubectl scale deployment backend --replicas=3 -n microservices-demo
# The queue is growing. Scale the workers.
kubectl scale deployment worker --replicas=5 -n microservices-demo
# The frontend is fine. Leave it at 1 replica.

This is one of the strongest arguments for microservices. In a monolith, scaling means running more copies of the entire application, including the parts that do not need scaling. In a microservices architecture, you scale only the bottleneck.

The backend Service distributes traffic across all backend pods automatically. The Service acts as a load balancer:

apiVersion: v1
kind: Service
metadata:
name: backend
namespace: microservices-demo
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 80

The selector app: backend matches all pods with that label, regardless of how many replicas exist. Add a replica, and it immediately starts receiving traffic.

Microservices are not always better than monoliths. The choice depends on team size, system complexity, and operational maturity.

When a monolith is better:

  • Small team (fewer than 10 developers)
  • Simple domain with few business capabilities
  • Early-stage project where the domain model is still evolving
  • Limited operational expertise (no CI/CD, no monitoring, no service mesh)

When microservices are better:

  • Multiple teams that need to deploy independently
  • Different services have different scaling requirements
  • Different services need different technology stacks
  • The domain is well-understood and boundaries are clear

The common mistake is starting with microservices. Start with a monolith. Split when the pain of the monolith (deploy conflicts, scaling bottlenecks, team coordination overhead) exceeds the pain of distributed systems (network failures, eventual consistency, operational complexity).

Martin Fowler calls this the “monolith first” approach. It is the safer path because merging microservices back into a monolith is much harder than splitting a monolith into microservices.

A monolith produces one log stream and one set of metrics. A microservices architecture produces N log streams and N sets of metrics. Debugging becomes harder because a single user request might touch five services.

Three pillars of observability address this:

Logs. Each service logs independently. In this demo, you check logs per service:

Terminal window
kubectl logs -f deploy/worker -n microservices-demo
kubectl logs -f deploy/backend -n microservices-demo

In production, you collect all logs into a central system (EFK stack, Loki, Splunk) so you can search across services.

Metrics. Each service exports metrics: request rate, error rate, latency, queue depth. Tools like Prometheus scrape these metrics and Grafana visualizes them. The backend’s /api/stats endpoint hints at this:

'{"total_orders":5,"pending":2,"processing":2,"completed":1,"queue_depth":3}'

Distributed tracing. A trace follows a single request through every service it touches. The Ingress assigns a trace ID. Each service passes it along. Tools like Jaeger or Zipkin collect these traces and show the full request path.

Without all three, debugging a production microservices system is like finding a needle in a haystack while blindfolded.

This demo is intentionally simplified. The backend returns static JSON instead of querying PostgreSQL. The worker logs messages instead of actually dequeuing from Redis. This keeps the manifests small and the dependencies minimal.

But the architecture is real. Five services. Three communication patterns (HTTP via Ingress, environment-variable-based service references, queue polling). Two data stores. One namespace providing logical isolation.

The patterns you see here, service discovery via DNS, configuration via environment variables, independent scaling via replica counts, background processing via workers and queues, are the same patterns used in production microservices systems at any scale.

  • API Gateway adds Kong in front of services for rate limiting and authentication.
  • Event-Driven Kafka replaces the simple Redis queue with Apache Kafka for durable, ordered event streaming.