Designing a five-service microservices architecture with RabbitMQ
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.