Breezy
Microservices social platform with real-time messaging
Results
- Eight deployable units (six microservices, Nginx gateway, Next.js web) with per-service databases; every feature planned in the MoSCoW backlog shipped, including the "could have" tier
- Real-time messaging (1-to-1 and group, read receipts, reply threads) over WebSocket, live notifications over SSE and Web Push, video streaming with HTTP range requests
- Logs, metrics and traces wired end to end (Pino → Promtail → Loki, Prometheus, OpenTelemetry → Jaeger, Grafana dashboards); CI runs tests with coverage on Codecov and generates an aggregated OpenAPI spec
Breezy is a short-form social network: posts, nested comments, likes, a follow graph with a personalized feed, media uploads, private messaging and real-time notifications. It was the deliverable for the Distributed Application Development course in my third year at CESI. The brief was to build the product as a set of independent services rather than a monolith, and to justify every communication choice between them.
I was tech lead on a team of four and authored roughly 300 of the ~480 commits: the web app, most of the services, the gateway configuration, the observability stack and the production Docker Compose.
Architecture
The repository is a Turborepo monorepo (npm workspaces). Shared packages (@breezy/logger, @breezy/observability, @breezy/emails, ESLint and TypeScript configs) sit next to the apps.
| Service | Store | Responsibility |
|---|---|---|
auth-service | PostgreSQL + Redis | Registration, login, email verification, 2FA, Google OAuth, JWT issuance |
profile-service | PostgreSQL | Profiles, avatars, follow graph; gRPC server for other services |
post-service | MongoDB | Posts, comments, likes, feed, search |
media-service | MongoDB (GridFS) | Image processing, video streaming with range requests; gRPC server |
message-service | MongoDB | 1-to-1 and group conversations, Socket.io WebSocket |
notifications-service | MongoDB | Consumes RabbitMQ events, pushes over SSE and Web Push, sends emails |
Persistence is polyglot on purpose: PostgreSQL where referential integrity matters (accounts, follow relationships), MongoDB where documents vary in shape (posts with optional media and nested replies), GridFS for large files, Redis for anything with a short TTL (refresh tokens, 2FA codes, verification tokens). No database is shared between services.
Four communication modes
REST through the gateway. Every browser request goes through Nginx, which terminates TLS, validates the JWT via auth_request against the auth service, applies rate limiting, and routes by path prefix to the right service. Two limit_req zones are defined: one for auth endpoints keyed by client IP, one for the API keyed by the Authorization header, so one abusive token cannot starve other users.
gRPC between services. Synchronous, low-latency calls with a strict contract: the post service asks the profile service for authors and the follow graph, the notifications service resolves recipients, the auth service checks whether a profile is complete to encode onboarding state into the JWT.
RabbitMQ for events. Auth, profile, post and message services publish to a breezy.events exchange; the notifications service consumes everything, and ban events are also consumed by post and profile services to clean up their own data. The publisher never knows who is listening.
SSE and WebSocket to the browser. Notifications are one-directional, so they use Server-Sent Events on /api/notifications/stream. Messaging needs both directions, so the message service runs Socket.io: each authenticated client joins a room keyed by its user id, which fans out to all of that user’s devices at once. New conversation messages also trigger Web Push for users who are not on the page.
Authentication and security
Passwords are hashed with Node’s native scrypt and a per-password random salt, stored as salt:hash. Access tokens are RS256 JWTs signed by the auth service only; other services verify the signature locally with the mounted public key, so authentication stays stateless and there is no call to the auth service on every request. Refresh tokens live in Redis with rotation, and the rotation is done in a Lua script so it is atomic. Roles (visitor, user, moderator, admin) map to a permission matrix (post:delete:own vs post:delete:any, user:suspend, user:ban) enforced in each service. helmet sets security headers and express-rate-limit backs up the Nginx zones.
Observability
Every service logs structured JSON with Pino (sensitive fields masked, request id correlation) to a shared volume; Promtail tails it into Loki. Every service exposes /metrics scraped by Prometheus and a health endpoint. OpenTelemetry instrumentation exports traces to Jaeger over OTLP. Grafana is provisioned from the repo with data sources and dashboards, including RabbitMQ monitoring. The first version of the logging chain was Filebeat → Elasticsearch → Kibana; I replaced it with Promtail and Loki because it was lighter to run and integrated into the same Grafana instance as the metrics.
Infrastructure and CI
Development runs only the infrastructure in Docker (databases, Redis, RabbitMQ, gateway) while services run on the host through Turborepo in watch mode. Production runs everything in containers built from multi-stage Dockerfiles, split across three Docker networks (gateway-net, services-net, db-net) so a service cannot reach a database it does not own. Every container has a healthcheck that gates dependent startups. The Nginx config is a template rendered with envsubst.
The pull-request workflow on GitHub Actions lints, type-checks, runs the Jest suites of every service with coverage uploaded to Codecov, and regenerates the OpenAPI spec aggregated from JSDoc annotations across services. Test and coverage tasks depend on type-checking in the Turborepo graph, so a type error fails fast.
Why these choices
Nginx as the only entry point. Authentication and rate limiting live in one place instead of being reimplemented in six services. Adding a service means adding a location block.
gRPC and RabbitMQ, not one or the other. Where the caller needs the answer to build its response (authors of a post), a synchronous call is the honest model. Where it does not (send a notification), a queue decouples availability: the notifications service can be down and events wait.
SSE for notifications, WebSocket for chat. SSE is plain HTTP, survives proxies and reconnects for free; it fits a one-way stream. WebSocket only where the client genuinely talks back.
Polyglot persistence. Picking one database for everything would have forced either joins in MongoDB or a rigid schema for posts. Each service picks what fits its data.
What I would do next
The report lists the evolutions we identified: replace the remaining synchronous gRPC calls with request/reply over RabbitMQ using correlation ids to remove the temporal coupling; add retries, dead-letter queues and idempotent consumers on the event bus; introduce versioned migrations for PostgreSQL and MongoDB; add contract tests between services and Playwright end-to-end tests on the web app; and move from Docker Compose to Kubernetes for rolling updates and self-healing.