An application that grows from a single large monolith will sooner or later reach a point where a single team can no longer deploy a small feature without fear of breaking other parts, and a build process that used to be fast turns slow because the entire codebase must be recompiled even if only a single module changes. Microservices solve this problem by breaking the application into a collection of small, independent services, each having specific responsibilities, its own database, and its own deployment lifecycle. Docker becomes a natural foundation for this pattern because each service can be packaged into a separate image, run as an independent container, and connected via network without needing to know the details of the environment where other services are running. This chapter discusses a case study of containerizing a simple e-commerce microservices system, starting from structuring the service architecture, managing inter-service communication, maintaining data consistency across databases, to implementing scaling patterns when traffic to one of the services increases.
36.1 Service Architecture in Microservices
The case study in this chapter uses two main services: product-service, which manages product data and stock, and order-service, which manages order data. Both services are intentionally made as small as possible so that the focus remains on architecture patterns and communication, rather than on business logic complexity.
36.1.1 Single Responsibility Principle and Database per Service
The core principle of microservices is single responsibility: one service handles only one business domain, and changes to that domain must not force other services to be redeployed. A direct consequence of this principle is the database per service pattern, where each service has its own database that is not directly accessed by other services. product-service owns the productdb database, while order-service owns the orderdb database, and neither ever performs direct queries against the other service's database.
This pattern differs significantly from monolithic architecture, which typically shares a single large database among modules. Database isolation gives each team the freedom to change schemas or even database types without disrupting other services, but as a consequence, data synchronization across services can no longer rely on standard SQL JOINs, but rather through API calls or event exchanges between services.
36.1.2 Structuring a Multi-Service Project
Structure the project directory so that each service has its own folder and Dockerfile, consistent with the principle that each service is an independent deployment unit.
microservices-demo/
├── product-service/
│ ├── Dockerfile
│ └── src/
├── order-service/
│ ├── Dockerfile
│ └── src/
├── gateway/
│ └── nginx.conf
└── compose.yamlproduct-service and order-service use simple Node.js Dockerfiles that share an identical pattern, differing only in the entrypoint name.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]Define both services along with their respective databases in compose.yaml. Each service is assigned its own internal network so that it can only be accessed via the gateway in front of it, rather than being exposed directly to the host.
services:
product-service:
build: ./product-service
environment:
DATABASE_URL: postgres://product:secret@product-db:5432/productdb
depends_on:
- product-db
networks:
- backend
product-db:
image: postgres:16
environment:
POSTGRES_DB: productdb
POSTGRES_USER: product
POSTGRES_PASSWORD: secret
volumes:
- product-data:/var/lib/postgresql/data
networks:
- backend
order-service:
build: ./order-service
environment:
DATABASE_URL: postgres://order_app:secret@order-db:5432/orderdb
PRODUCT_SERVICE_URL: http://product-service:3000
depends_on:
- order-db
- product-service
networks:
- backend
order-db:
image: postgres:16
environment:
POSTGRES_DB: orderdb
POSTGRES_USER: order_app
POSTGRES_PASSWORD: secret
volumes:
- order-data:/var/lib/postgresql/data
networks:
- backend
networks:
backend:
volumes:
product-data:
order-data:Notice that no ports are mapped to the host in this block. All access to product-service and order-service goes through a single entry point via an API gateway, rather than direct port mapping to each service. Run docker compose config to validate the syntax before actually running this stack.
docker compose config --quietThis command produces no output if compose.yaml is valid, and prints a detailed error message complete with line numbers if there are YAML syntax errors or missing service references.
36.2 Inter-Service Communication
Once business logic is distributed across multiple services, communication between services becomes the most crucial part of the overall system. This case study employs two patterns simultaneously: synchronous communication via REST API for needs requiring an immediate response, and asynchronous communication via message broker for requirements that can be processed later.
36.2.1 Synchronous Communication via REST API
order-service needs to ensure that the ordered product exists and has sufficient stock before an order is created, so it calls product-service synchronously via REST API every time it receives a new order. The Compose service name, in this case product-service, functions directly as a hostname resolvable via Docker internal DNS, following the same pattern discussed in container networking chapters.
// order-service/src/index.js (snippet)
const res = await fetch(`${process.env.PRODUCT_SERVICE_URL}/products/${productId}`);
if (!res.ok) {
throw new Error("Product not found or insufficient stock");
}Synchronous communication patterns like this are easy to understand because they resemble regular function calls, but they carry real risks in production: if product-service responds slowly or goes down, order-service gets delayed waiting for the response. Developers building synchronous inter-service communication should always add explicit timeouts to every HTTP call, preventing a single troubled service from causing other services to hang indefinitely.
36.2.2 Asynchronous Communication via Message Broker
After an order is successfully created, order-service needs to notify that the associated product stock should be decremented. This process does not require an immediate response, making it suitable for an asynchronous pattern via a message broker. Add the rabbitmq service to the compose.yaml created in the previous subsection.
services:
rabbitmq:
image: rabbitmq:3.13-management
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: secret
ports:
- "15672:15672"
networks:
- backendPort 15672 is mapped to the host so that the RabbitMQ management UI can be accessed via browser for queue monitoring purposes, while port 5672 for AMQP inter-service communication only needs to be accessed via the backend internal network without needing to be mapped to the host. The RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS variables must be set here because the default user guest in the official RabbitMQ image is only allowed to log in via direct loopback connections; connections entering via Docker mapped ports are considered coming from outside loopback, according to official RabbitMQ image notes on Docker Hub, so logging in as guest/guest via a browser on the host will be rejected if these two variables are omitted.
Also add RABBITMQ_URL to the previously defined order-service and product-service services, so both know the broker address to connect to.
services:
order-service:
environment:
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
- rabbitmq
product-service:
environment:
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
- rabbitmqorder-service publishes the order.created event as soon as the order is saved to the database.
// order-service/src/index.js (snippet)
await channel.assertExchange("orders", "fanout", { durable: true });
channel.publish("orders", "", Buffer.from(JSON.stringify({ orderId, productId, qty })));product-service acts as a consumer listening to that exchange, decrementing stock as soon as the event is received.
// product-service/src/index.js (snippet)
const q = await channel.assertQueue("", { exclusive: true });
await channel.bindQueue(q.queue, "orders", "");
channel.consume(q.queue, async (msg) => {
const { productId, qty } = JSON.parse(msg.content.toString());
await db.query("UPDATE products SET stock = stock - $1 WHERE id = $2", [qty, productId]);
channel.ack(msg);
});The fanout exchange pattern is selected so that the order.created event can be listened to by multiple consumers in the future, such as an email notification service, without modifying the code in order-service as the publisher. After running the stack, open the RabbitMQ management UI at http://localhost:15672 and log in using the admin/secret credentials configured via RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS to verify that the orders exchange is created and messages flow whenever a new order is placed.
36.2.3 API Gateway as a Single Entry Point
External clients, whether web or mobile applications, should not need to know how many services exist behind the scenes or on which ports each is running. An API gateway solves this problem by acting as a single entry point that forwards requests to the correct service based on the path. Create the following gateway/nginx.conf to forward requests to product-service and order-service.
events {}
http {
resolver 127.0.0.11 valid=10s;
server {
listen 80;
location /products/ {
set $product_upstream product-service:3000;
proxy_pass http://$product_upstream;
}
location /orders/ {
set $order_upstream order-service:3000;
proxy_pass http://$order_upstream;
}
}
}The resolver 127.0.0.11 directive instructs Nginx to use Docker's built-in embedded DNS server, which is always available at that address in every container, per Docker's official documentation on embedded DNS servers. The proxy_pass here intentionally uses variables for target addresses ($product_upstream, $order_upstream) instead of static upstream blocks commonly used for a single backend. Nginx resolves hostnames in static upstream blocks only once during start or reload process; thus, if product-service is scaled to multiple instances later, the gateway will not automatically recognize new instances without a manual reload. Using variables in proxy_pass forces Nginx to periodically re-resolve hostnames following the cache TTL set via the valid parameter in the resolver directive, as per official Nginx documentation, allowing newly scaled instances to be reachable without restarting the gateway.
Note that proxy_pass above does not include an additional path after the variable address. Official Nginx documentation notes that location prefix replacement is unreliable when proxy_pass uses variables, meaning the original client path (e.g., /products/1) is passed as-is to the backend. That is why routes in product-service and order-service are defined with complete /products and /orders prefixes from the start, rather than just /:id, to match the paths passed by this gateway.
Add the gateway service to compose.yaml, and move port mapping here since only the gateway needs direct host access.
services:
gateway:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- product-service
- order-service
networks:
- backendRun the full stack, then test the gateway via curl to verify routing to each service functions correctly.
docker compose up -d
curl http://localhost:8080/products/1
curl -X POST http://localhost:8080/orders -d '{"productId":1,"qty":2}' -H "Content-Type: application/json"If the gateway returns 502 Bad Gateway, the most common cause in production is that the target service is not ready to accept connections when Nginx first resolves its DNS, especially if backend containers take longer to initialize than Nginx. Add a healthcheck to backend services and modify depends_on to use the service_healthy condition, in accordance with Compose Specification support for dependency conditions, ensuring the gateway waits for backends to be ready before forwarding traffic.
36.3 Data Consistency in Microservices
Splitting databases across services presents data consistency challenges because operations that used to be a single database transaction in a monolith are now spread across multiple services with separate databases that cannot perform cross-boundary ACID transactions.
36.3.1 Trade-off Between Strong Consistency and Eventual Consistency
A monolith with a single database usually relies on strong consistency, where all data changes are immediately consistent upon transaction completion due to ACID guarantees. Microservices using database per service, like product-service and order-service in this case study, must accept that order data and product stock data might temporarily be out of sync, a condition known as eventual consistency. The time gap between an order being recorded and stock actually being decremented, while usually just a few milliseconds via a message broker, remains a window where data is not fully consistent.
This trade-off is accepted for a key benefit: each service can operate independently even if another service experiences issues. If product-service is down, an event-driven order-service can still accept new orders and defer stock reduction until product-service comes back online and processes pending messages in RabbitMQ, instead of failing entirely as happens in a monolith sharing one database.
36.3.2 Event-Driven Synchronization and Idempotency
The asynchronous communication pattern via message broker built in the inter-service communication subsection is the most common form of event-driven synchronization: data changes in one service trigger an event, and other services react to keep their data eventually consistent. A detail frequently overlooked by developers new to this pattern is the requirement for idempotency on the consumer side: message brokers like RabbitMQ, depending on the acknowledgment mode used, can redeliver the same message more than once, such as when a consumer connection drops right before sending an ack.
Without idempotency handling, an order.created event processed twice by product-service will deduct stock twice for a single order, creating a bug that is hard to trace because no error appears in logs. Add an event tracking column to the table in product-service to prevent duplicate processing.
// product-service/src/index.js (snippet)
const alreadyProcessed = await db.query(
"SELECT 1 FROM processed_events WHERE event_id = $1", [orderId]
);
if (alreadyProcessed.rowCount === 0) {
await db.query("UPDATE products SET stock = stock - $1 WHERE id = $2", [qty, productId]);
await db.query("INSERT INTO processed_events (event_id) VALUES ($1)", [orderId]);
}
channel.ack(msg);Field experience shows data consistency bugs in microservices almost always stem from two issues: non-idempotent consumers as described above, or out-of-order event arrivals due to parallel processing by multiple consumers. Sysadmin or DevOps engineers investigating inconsistent data incidents in production should inspect consumer logs and event tracker tables before suspecting business logic bugs.
36.4 Scaling Patterns in Microservices
One primary advantage of breaking applications into microservices is the ability to scale each service independently based on its specific load, rather than scaling an entire monolithic application when only a small component experiences high traffic.
36.4.1 Horizontal Scaling with Docker Compose
Suppose product-service receives significantly higher traffic than order-service because many users are browsing the product catalog. Scale this service horizontally using the --scale flag in docker compose up, per official Compose CLI reference documentation.
docker compose up -d --scale product-service=3This command launches three container instances of product-service simultaneously. Services scaled this way must not use static host port mappings (such as "3000:3000") because each instance will contend for the same host port, causing a port is already allocated error. That is why product-service in this case study maps no ports to the host, as access occurs exclusively via the gateway. Verify the number of active instances using docker compose ps.
docker compose ps product-serviceThe output shows three container rows appended with sequential numbers (e.g., microservices-demo-product-service-1 to -3), confirming successful scaling.
36.4.2 Load Balancing in Front of Scaled Services
Three product-service instances offer little benefit if traffic routes to only one instance. Docker internal DNS returns the addresses of all running product-service instances whenever a query is made for that service name, including newly scaled instances. Because gateway/nginx.conf in the inter-service communication section is configured using variable-based proxy_pass with a resolver directive, the gateway automatically re-resolves product-service whenever the cache TTL (10 seconds, matching valid=10s) expires, allowing all three instances to be reached without restarting or reloading the gateway manually.
Verify this by adding an endpoint that returns the container hostname in product-service, then send several requests to the gateway with pauses over 10 seconds between calls so each request forces Nginx to re-resolve DNS rather than using cached resolution results.
for i in 1 2 3; do curl -s http://localhost:8080/products/health; echo; sleep 11; doneIf the hostname in the response alternates across calls, the gateway is successfully reaching multiple instances. The precise algorithm Nginx uses to select an address from DNS resolution results during each cycle is an internal resolver implementation detail, meaning distribution might not alternate perfectly on every single request; the critical factor is verifying that all three instances receive traffic over longer time intervals rather than a single instance taking all load continuous work.
Monitor resource usage across instances via docker stats to confirm traffic distributes evenly and no single instance becomes a bottleneck due to other factors, such as restrictive database connection limits on product-db.
docker stats --filter "name=product-service"Horizontal scaling is effective only if the scaled service is stateless, meaning it does not store critical data in container memory or local filesystems. product-service in this case study is safe to scale because all data is persisted in the separate product-db rather than inside the service container itself. Sysadmin or DevOps engineers scaling services with similar patterns in production should always confirm stateless requirements are met before increasing instance counts.

