Why I Built This

Every microservices tutorial shows you how to build one service. Few show you how to build five that actually talk to each other, handle failures gracefully, and deploy to Kubernetes. I wanted to build the complete picture - not a toy demo, but a production-shaped system with real event-driven communication, database isolation, health checks, and CI/CD.

The result is a Turborepo monorepo with five NestJS services, shared packages for DTOs and configuration, RabbitMQ for async messaging, PostgreSQL (one database per service), and a full Kubernetes deployment.

The Five Services

API Gateway (Port 3000)

The gateway is the single entry point. It doesn't contain business logic - it routes, authenticates, and aggregates.

The \ProxyService\ maintains Axios clients for each downstream service. When a request hits \/auth/register\, the \AuthController\ delegates to \proxyService.forward('auth', req.method, path, req.body, req.headers)\. The gateway strips hop-by-hop headers, adds correlation IDs, and forwards cleanly.

Health aggregation was a nice touch: the gateway's \/health\ endpoint calls every downstream service's \/health\ endpoint in parallel and reports an aggregate status: \healthy\ (all services up), \degraded\ (some down), or \unhealthy\ (all down). The readiness check specifically requires the auth service to be healthy - if users can't authenticate, the system isn't ready.

Auth Service (Port 3001)

Handles registration, JWT tokens, password reset, and \/auth/me\. Uses Passport.js with a custom JWT strategy and Prisma for user storage.

When a user registers, the service publishes \auth.user.registered\ to RabbitMQ. The User Service picks this up and creates a profile. The Email Service sends a welcome email. The Analytics Service tracks the signup. The Auth Service has no idea who's listening - and that's the point.

User Service (Port 3002)

Manages user profiles, preferences, and addresses. Listens for \auth.user.registered\ events to auto-create profiles. Publishes \user.profile.updated\ and \user.deleted\ events for downstream consumers.

Product Service (Port 3003)

Product catalog with categories, inventory tracking, and search. Publishes events for product creation, updates, deletions, inventory changes, and low-stock alerts. The \LowStockAlertEvent\ is particularly useful - it lets a notification service alert admins when inventory drops below a threshold.

Payment Service (Port 3004)

Orders and payment processing. Listens for product events to maintain a denormalized product cache (so it doesn't need to call the Product Service synchronously for order validation).

Event-Driven Communication: The Details

Every service has its own \EventService\ that connects to RabbitMQ on module initialization, declares a topic exchange (\microservices.events\), and provides typed publish methods.

Connection Resilience

Each \EventService\ implements \OnModuleInit\ and \OnModuleDestroy\ for lifecycle management. If RabbitMQ isn't available at startup, the service retries every 5 seconds (\setTimeout(() => this.connect(), this.retryDelay)\). Connection and channel error handlers log failures and set an \isConnected\ flag. The \ensureConnection()\ method re-establishes the connection before any publish attempt.

This means services start and operate even if RabbitMQ is temporarily down - they just can't publish events until the connection recovers. No crashes, no blocked startups.

Event Structure

I standardized the event payload format across all services:

  • \eventType\: the routing key (e.g., \user.profile.updated\)
  • \data\: the event payload (strongly typed per event)
  • \timestamp\: ISO 8601
  • \service\: the publishing service name
  • \version\: for future compatibility

Messages are published as persistent JSON buffers with unique message IDs, ensuring they survive RabbitMQ restarts.

Naming Convention

All events follow \{service}.{entity}.{action}\ - \auth.user.registered\, \product.inventory.low_stock\, \payment.order.status.updated\. This makes routing key patterns trivial: a service that cares about all user events subscribes to \*.user.*\.

Database Per Service

Each service has its own Prisma schema, its own migrations directory, and its own database connection string. The auth service stores credentials and tokens. The user service stores profiles. The product service stores the catalog. There's zero shared database state.

The trade-off is real: if the Payment Service needs product details for an order, it can't just JOIN against the products table. It either listens for product events and maintains a local cache, or it makes a synchronous HTTP call through the gateway. I chose the event-based materialized view approach for frequently-accessed data and HTTP calls for rare lookups.

Health Checks: Three Levels Deep

Every service exposes three health endpoints:

  • \/health\ - full check (database connectivity, uptime, version)
  • \/health/ready\ - readiness probe (can the service handle requests?)
  • \/health/live\ - liveness probe (is the process alive?)

The shared \HealthService\ in the packages provides reusable checkers for PostgreSQL, Redis, and RabbitMQ. Each service composes its own health check from these building blocks. The Kubernetes manifests reference these endpoints for pod readiness and liveness probes.

Kubernetes Deployment

The \k8s/\ directory contains production-ready manifests:

  • Deployments for each service with resource limits and requests
  • Services for internal DNS-based discovery
  • ConfigMaps and Secrets for environment configuration
  • HorizontalPodAutoscaler for traffic-based scaling
  • Readiness/liveness probes pointing to the health endpoints

Docker: Multi-Stage Builds

Each service has a Dockerfile with a three-stage build: \deps\ (install dependencies), \builder\ (compile TypeScript, generate Prisma client), and \runner\ (production image with minimal footprint). The shared packages are built first (\@microservices/shared\, \@microservices/config\), then the service itself.

Shared Packages

The \packages/\ directory contains two packages:

  • \@microservices/shared\ - common DTOs, event type constants (\AUTH_EVENTS\, \USER_EVENTS\, \PRODUCT_EVENTS\, \PAYMENT_EVENTS\), health check utilities, and Swagger configuration.
  • \@microservices/config\ - RabbitMQ connection config factory, queue naming utilities, and environment helpers.

Having event type constants in a shared package means the publisher and consumer always agree on the routing key string. No typos, no drift.

What I Learned

  1. Event-driven architecture is harder to debug. When something goes wrong, the error might be in the publishing service, the exchange configuration, the routing key, the consumer's deserialization, or the consumer's business logic. Good logging and correlation IDs are essential.
  2. Database-per-service is worth the complexity. Independent deployability and failure isolation justify the overhead of eventual consistency.
  3. Health checks aren't optional. In a Kubernetes world, readiness and liveness probes are the difference between a self-healing system and cascading failures.
  4. Monorepo tooling matters. Turborepo's task graph and caching made it possible to develop five services simultaneously without losing my mind.

Full source: github.com/ionutn0301/microservices-starter-kit

Back to Insights