microservicesdocker

Designing a five-service microservices architecture with RabbitMQ

Aug 22, 2026 3 min read

The Social Media Platform project started as a question: what actually changes when you split a monolith into services that fail independently instead of together? The answer is five services — an API gateway, auth, posts, notifications, and media — each with its own database access, talking to each other through RabbitMQ instead of direct HTTP calls wherever the coupling isn't required.

Centralized routing through the API Gateway

Every request from the client hits one service first: the gateway. It's the only service exposed publicly, and it does two things before anything else happens — validates the caller's JWT and routes the request to whichever internal service owns that resource. Auth, posts, and notifications all sit on a private network behind it, reachable only from the gateway or from each other. That means adding rate limiting, request logging, or swapping an internal service's implementation never touches the other four — they don't know or care that the gateway exists.

Asynchronous messaging with RabbitMQ

Not every interaction needs a direct call. When someone posts, the notifications service needs to know about it, but the posts service shouldn't have to wait on notifications being healthy to finish a write. That's what the message broker is for: posts publishes an event and moves on, notifications picks it up whenever it's ready.

const amqp = require("amqplib");
 
async function publishPostCreated(event) {
  const connection = await amqp.connect(process.env.RABBITMQ_URL);
  const channel = await connection.createChannel();
  const exchange = "posts.events";
 
  await channel.assertExchange(exchange, "topic", { durable: true });
  channel.publish(
    exchange,
    "post.created",
    Buffer.from(JSON.stringify(event)),
    { persistent: true }
  );
 
  await channel.close();
  await connection.close();
}
 
async function consumePostCreated(onEvent) {
  const connection = await amqp.connect(process.env.RABBITMQ_URL);
  const channel = await connection.createChannel();
  const exchange = "posts.events";
  const queue = "notifications.post-created";
 
  await channel.assertExchange(exchange, "topic", { durable: true });
  await channel.assertQueue(queue, { durable: true });
  await channel.bindQueue(queue, exchange, "post.created");
 
  channel.consume(queue, async (msg) => {
    if (!msg) return;
    await onEvent(JSON.parse(msg.content.toString()));
    channel.ack(msg);
  });
}

The topic exchange is what makes this extensible — a media-processing service can bind its own queue to the same post.created routing key later without posts ever needing a code change to know it exists.

Redis-based rate limiting

The gateway is also where per-user rate limiting lives, backed by Redis so the counter is shared across every gateway instance rather than living in a single process's memory:

const rateLimit = (limit, windowSeconds) => async (req, res, next) => {
  const key = `ratelimit:${req.user.id}`;
  const requests = await redis.incr(key);
 
  if (requests === 1) {
    await redis.expire(key, windowSeconds);
  }
 
  if (requests > limit) {
    return res.status(429).json({ error: "Too many requests" });
  }
 
  next();
};
 
app.use("/api/posts", rateLimit(100, 60));

Redis is doing double duty in this system beyond rate limiting — it's also the cache in front of the posts service's hottest reads, which matters more once requests are hopping through a gateway and a message broker instead of hitting a monolith directly.

Running it all with Docker Compose

Five services plus RabbitMQ, Redis, and MongoDB is a lot to run by hand, so local development is one docker compose up:

services:
  gateway:
    build: ./gateway
    ports:
      - "3000:3000"
    depends_on:
      - auth
      - posts
      - notifications
 
  auth:
    build: ./auth-service
    environment:
      - MONGO_URI=mongodb://mongo:27017/auth
    depends_on:
      - mongo
 
  posts:
    build: ./posts-service
    environment:
      - MONGO_URI=mongodb://mongo:27017/posts
      - RABBITMQ_URL=amqp://rabbitmq:5672
    depends_on:
      - mongo
      - rabbitmq
 
  notifications:
    build: ./notifications-service
    environment:
      - RABBITMQ_URL=amqp://rabbitmq:5672
      - REDIS_URL=redis://redis:6379
    depends_on:
      - rabbitmq
      - redis
 
  media:
    build: ./media-service
    environment:
      - CLOUDINARY_URL=${CLOUDINARY_URL}
 
  mongo:
    image: mongo:7
    volumes:
      - mongo-data:/data/db
 
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"
 
  redis:
    image: redis:7-alpine
 
volumes:
  mongo-data:

Every service builds from its own Dockerfile and only declares the dependencies it actually talks to — media doesn't even touch Mongo or RabbitMQ, because nothing about processing an image upload needs either. That one-to-one mapping between the compose file and the real network boundaries is what makes the local setup a reasonably honest stand-in for how the pieces would be deployed for real.