Microservices Architecture with Node.js: Complete Guide
Learn how to design and implement a robust microservices architecture using Node.js with Clean Architecture, DDD, and SOLID principles.
This content is free! Help keep the project running.
0737160d-e98f-4a65-8392-5dba70e7ff3eMicroservices have revolutionized the way we build scalable applications. In this comprehensive guide, I'll share my experience of over 10 years working with distributed architectures, applying Clean Architecture, DDD, and SOLID principles.
Why Microservices?
Before diving into implementation, it's crucial to understand when microservices make sense:
Benefits
- Independent scalability: Scale only the services that need it
- Independent deployment: Update one service without affecting others
- Heterogeneous technologies: Use the best tool for each problem
- Resilience: Failure in one service doesn't bring down the entire system
- Clear ownership: Teams can own specific services
Challenges
- Increased operational complexity
- Service-to-service communication
- Distributed data consistency
- Observability and debugging
- Network latency
Fundamentals: SOLID, Clean Architecture, and DDD
Before structuring our microservice, let's understand the principles that will guide our decisions.
SOLID Principles
| Principle | Description | Microservices Application |
|---|---|---|
| Single Responsibility | A class should have only one reason to change | Each service has a well-defined responsibility |
| Open/Closed | Open for extension, closed for modification | Use interfaces and dependency injection |
| Liskov Substitution | Subtypes must be substitutable for their base types | Consistent API contracts |
| Interface Segregation | Many specific interfaces are better than one general | Focused and cohesive APIs |
| Dependency Inversion | Depend on abstractions, not implementations | Inner layers don't know about frameworks |
Clean Architecture
Clean Architecture organizes code in concentric layers, where dependencies always point inward:
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Application │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Domain │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │
│ │ │ └─────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Domain-Driven Design (DDD)
Essential concepts we'll use:
- Entities: Objects with unique identity
- Value Objects: Immutable objects without identity
- Aggregates: Cluster of entities treated as a unit
- Repositories: Abstraction for persistence
- Domain Services: Logic that doesn't belong to an entity
- Domain Events: Notifications of something that happened in the domain
Microservice Folder Structure
Here's the complete structure following Clean Architecture and DDD:
user-service/
├── src/
│ ├── @types/ # Global type definitions
│ │ └── express.d.ts
│ │
│ ├── domain/ # Domain Layer (core)
│ │ ├── entities/
│ │ │ ├── User.ts
│ │ │ └── index.ts
│ │ │
│ │ ├── value-objects/
│ │ │ ├── Email.ts
│ │ │ ├── Password.ts
│ │ │ ├── UserId.ts
│ │ │ └── index.ts
│ │ │
│ │ ├── events/
│ │ │ ├── UserCreatedEvent.ts
│ │ │ ├── UserUpdatedEvent.ts
│ │ │ └── index.ts
│ │ │
│ │ ├── repositories/
│ │ │ └── IUserRepository.ts
│ │ │
│ │ ├── services/
│ │ │ └── IPasswordHasher.ts
│ │ │
│ │ └── errors/
│ │ ├── DomainError.ts
│ │ ├── UserNotFoundError.ts
│ │ ├── EmailAlreadyExistsError.ts
│ │ └── index.ts
│ │
│ ├── application/ # Application Layer (use cases)
│ │ ├── use-cases/
│ │ │ ├── CreateUser/
│ │ │ │ ├── CreateUserUseCase.ts
│ │ │ │ ├── CreateUserDTO.ts
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── GetUser/
│ │ │ │ ├── GetUserUseCase.ts
│ │ │ │ ├── GetUserDTO.ts
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── UpdateUser/
│ │ │ │ ├── UpdateUserUseCase.ts
│ │ │ │ ├── UpdateUserDTO.ts
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── DeleteUser/
│ │ │ │ ├── DeleteUserUseCase.ts
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ └── ListUsers/
│ │ │ ├── ListUsersUseCase.ts
│ │ │ ├── ListUsersDTO.ts
│ │ │ └── index.ts
│ │ │
│ │ ├── services/
│ │ │ ├── IEventPublisher.ts
│ │ │ └── ILogger.ts
│ │ │
│ │ └── errors/
│ │ ├── ApplicationError.ts
│ │ ├── ValidationError.ts
│ │ └── index.ts
│ │
│ ├── infrastructure/ # Infrastructure Layer
│ │ ├── database/
│ │ │ ├── prisma/
│ │ │ │ ├── schema.prisma
│ │ │ │ └── migrations/
│ │ │ ├── repositories/
│ │ │ │ └── PrismaUserRepository.ts
│ │ │ └── connection.ts
│ │ │
│ │ ├── messaging/
│ │ │ ├── RabbitMQEventPublisher.ts
│ │ │ ├── consumers/
│ │ │ │ └── UserEventsConsumer.ts
│ │ │ └── connection.ts
│ │ │
│ │ ├── cache/
│ │ │ ├── RedisCache.ts
│ │ │ └── ICache.ts
│ │ │
│ │ ├── services/
│ │ │ ├── BcryptPasswordHasher.ts
│ │ │ └── WinstonLogger.ts
│ │ │
│ │ └── config/
│ │ ├── env.ts
│ │ └── index.ts
│ │
│ ├── presentation/ # Presentation Layer (API)
│ │ ├── http/
│ │ │ ├── controllers/
│ │ │ │ ├── UserController.ts
│ │ │ │ └── HealthController.ts
│ │ │ │
│ │ │ ├── middlewares/
│ │ │ │ ├── errorHandler.ts
│ │ │ │ ├── requestLogger.ts
│ │ │ │ ├── rateLimiter.ts
│ │ │ │ ├── authenticate.ts
│ │ │ │ └── validate.ts
│ │ │ │
│ │ │ ├── routes/
│ │ │ │ ├── user.routes.ts
│ │ │ │ ├── health.routes.ts
│ │ │ │ └── index.ts
│ │ │ │
│ │ │ ├── validators/
│ │ │ │ ├── createUserValidator.ts
│ │ │ │ └── updateUserValidator.ts
│ │ │ │
│ │ │ └── presenters/
│ │ │ └── UserPresenter.ts
│ │ │
│ │ └── grpc/ # Optional: gRPC
│ │ ├── protos/
│ │ │ └── user.proto
│ │ └── handlers/
│ │ └── UserHandler.ts
│ │
│ ├── shared/ # Shared code
│ │ ├── container/
│ │ │ └── index.ts # Dependency injection
│ │ │
│ │ ├── patterns/
│ │ │ ├── CircuitBreaker.ts
│ │ │ ├── RetryWithBackoff.ts
│ │ │ └── index.ts
│ │ │
│ │ └── utils/
│ │ ├── Result.ts # Either/Result pattern
│ │ └── Guard.ts # Validations
│ │
│ ├── app.ts # Express configuration
│ └── server.ts # Entry point
│
├── tests/
│ ├── unit/
│ │ ├── domain/
│ │ │ └── entities/
│ │ │ └── User.spec.ts
│ │ └── application/
│ │ └── use-cases/
│ │ └── CreateUserUseCase.spec.ts
│ │
│ ├── integration/
│ │ └── repositories/
│ │ └── PrismaUserRepository.spec.ts
│ │
│ └── e2e/
│ └── user.e2e.spec.ts
│
├── docker/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ └── docker-compose.yml
│
├── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ └── hpa.yaml
│
├── .env.example
├── .eslintrc.js
├── .prettierrc
├── jest.config.js
├── tsconfig.json
├── package.json
└── README.md
Detailed Implementation
1. Domain Layer
The innermost layer, without external dependencies.
Entity: User
// src/domain/entities/User.tsimport { Email } from '../value-objects/Email';import { Password } from '../value-objects/Password';import { UserId } from '../value-objects/UserId';import { UserCreatedEvent } from '../events/UserCreatedEvent';import { DomainEvent } from '../events/DomainEvent'; interface UserProps { id: UserId; email: Email; password: Password; name: string; isActive: boolean; createdAt: Date; updatedAt: Date;} export class User { private readonly props: UserProps; private domainEvents: DomainEvent[] = []; private constructor(props: UserProps) { this.props = props; } // Factory method - single creation point static create(props: { email: Email; password: Password; name: string; }): User { const user = new User({ id: UserId.create(), email: props.email, password: props.password, name: props.name, isActive: true, createdAt: new Date(), updatedAt: new Date(), }); // Register domain event user.addDomainEvent(new UserCreatedEvent(user)); return user; } // Reconstitute from database static reconstitute(props: UserProps): User { return new User(props); } // Getters - encapsulation get id(): UserId { return this.props.id; } get email(): Email { return this.props.email; } get name(): string { return this.props.name; } get isActive(): boolean { return this.props.isActive; } get createdAt(): Date { return this.props.createdAt; } // Domain behaviors updateEmail(newEmail: Email): void { this.props.email = newEmail; this.props.updatedAt = new Date(); } updateName(newName: string): void { if (newName.length < 2) { throw new Error('Name must have at least 2 characters'); } this.props.name = newName; this.props.updatedAt = new Date(); } deactivate(): void { this.props.isActive = false; this.props.updatedAt = new Date(); } activate(): void { this.props.isActive = true; this.props.updatedAt = new Date(); } validatePassword(plainPassword: string): boolean { return this.props.password.compare(plainPassword); } // Domain Events private addDomainEvent(event: DomainEvent): void { this.domainEvents.push(event); } pullDomainEvents(): DomainEvent[] { const events = [...this.domainEvents]; this.domainEvents = []; return events; }}Value Object: Email
// src/domain/value-objects/Email.tsimport { DomainError } from '../errors/DomainError'; export class Email { private readonly value: string; private constructor(email: string) { this.value = email.toLowerCase().trim(); } static create(email: string): Email { if (!email) { throw new DomainError('Email is required'); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw new DomainError('Invalid email format'); } return new Email(email); } getValue(): string { return this.value; } equals(other: Email): boolean { return this.value === other.value; } toString(): string { return this.value; }}Value Object: Password
// src/domain/value-objects/Password.tsimport bcrypt from 'bcrypt';import { DomainError } from '../errors/DomainError'; export class Password { private readonly hashedValue: string; private readonly isHashed: boolean; private constructor(value: string, isHashed: boolean) { this.hashedValue = value; this.isHashed = isHashed; } static create(plainPassword: string): Password { if (!plainPassword || plainPassword.length < 8) { throw new DomainError('Password must have at least 8 characters'); } if (!/[A-Z]/.test(plainPassword)) { throw new DomainError('Password must have at least one uppercase letter'); } if (!/[0-9]/.test(plainPassword)) { throw new DomainError('Password must have at least one number'); } const hashed = bcrypt.hashSync(plainPassword, 12); return new Password(hashed, true); } static fromHashed(hashedPassword: string): Password { return new Password(hashedPassword, true); } compare(plainPassword: string): boolean { return bcrypt.compareSync(plainPassword, this.hashedValue); } getValue(): string { return this.hashedValue; }}Repository Interface
// src/domain/repositories/IUserRepository.tsimport { User } from '../entities/User';import { Email } from '../value-objects/Email';import { UserId } from '../value-objects/UserId'; export interface IUserRepository { findById(id: UserId): Promise<User | null>; findByEmail(email: Email): Promise<User | null>; findAll(options?: { page?: number; limit?: number; filter?: { isActive?: boolean }; }): Promise<{ users: User[]; total: number }>; save(user: User): Promise<void>; delete(id: UserId): Promise<void>; exists(email: Email): Promise<boolean>;}2. Application Layer
Orchestrates use cases using domain entities.
Result Pattern
// src/shared/utils/Result.tsexport class Result<T, E = Error> { private constructor( private readonly _isSuccess: boolean, private readonly _value?: T, private readonly _error?: E ) {} static ok<T>(value: T): Result<T, never> { return new Result(true, value); } static fail<E>(error: E): Result<never, E> { return new Result(false, undefined, error); } isSuccess(): boolean { return this._isSuccess; } isFailure(): boolean { return !this._isSuccess; } getValue(): T { if (!this._isSuccess) { throw new Error('Cannot get value of a failed result'); } return this._value as T; } getError(): E { if (this._isSuccess) { throw new Error('Cannot get error of a successful result'); } return this._error as E; }}Use Case: CreateUser
// src/application/use-cases/CreateUser/CreateUserUseCase.tsimport { User } from '../../../domain/entities/User';import { Email } from '../../../domain/value-objects/Email';import { Password } from '../../../domain/value-objects/Password';import { IUserRepository } from '../../../domain/repositories/IUserRepository';import { IEventPublisher } from '../../services/IEventPublisher';import { ILogger } from '../../services/ILogger';import { Result } from '../../../shared/utils/Result';import { CreateUserDTO, CreateUserResponseDTO } from './CreateUserDTO';import { EmailAlreadyExistsError } from '../../../domain/errors'; export class CreateUserUseCase { constructor( private readonly userRepository: IUserRepository, private readonly eventPublisher: IEventPublisher, private readonly logger: ILogger ) {} async execute( dto: CreateUserDTO ): Promise<Result<CreateUserResponseDTO, Error>> { try { // 1. Create Value Objects (domain validation) const email = Email.create(dto.email); const password = Password.create(dto.password); // 2. Check business rules const emailExists = await this.userRepository.exists(email); if (emailExists) { return Result.fail(new EmailAlreadyExistsError(dto.email)); } // 3. Create entity const user = User.create({ email, password, name: dto.name, }); // 4. Persist await this.userRepository.save(user); // 5. Publish domain events const domainEvents = user.pullDomainEvents(); for (const event of domainEvents) { await this.eventPublisher.publish(event); } this.logger.info('User created successfully', { userId: user.id.getValue() }); // 6. Return response DTO return Result.ok({ id: user.id.getValue(), email: user.email.getValue(), name: user.name, createdAt: user.createdAt, }); } catch (error) { this.logger.error('Error creating user', { error, dto }); return Result.fail(error as Error); } }}3. Infrastructure Layer
Concrete implementations of interfaces.
Repository: Prisma
// src/infrastructure/database/repositories/PrismaUserRepository.tsimport { PrismaClient } from '@prisma/client';import { User } from '../../../domain/entities/User';import { Email } from '../../../domain/value-objects/Email';import { Password } from '../../../domain/value-objects/Password';import { UserId } from '../../../domain/value-objects/UserId';import { IUserRepository } from '../../../domain/repositories/IUserRepository'; export class PrismaUserRepository implements IUserRepository { constructor(private readonly prisma: PrismaClient) {} async findById(id: UserId): Promise<User | null> { const data = await this.prisma.user.findUnique({ where: { id: id.getValue() }, }); if (!data) return null; return this.toDomain(data); } async findByEmail(email: Email): Promise<User | null> { const data = await this.prisma.user.findUnique({ where: { email: email.getValue() }, }); if (!data) return null; return this.toDomain(data); } async save(user: User): Promise<void> { const data = this.toPersistence(user); await this.prisma.user.upsert({ where: { id: data.id }, create: data, update: data, }); } async delete(id: UserId): Promise<void> { await this.prisma.user.delete({ where: { id: id.getValue() }, }); } async exists(email: Email): Promise<boolean> { const count = await this.prisma.user.count({ where: { email: email.getValue() }, }); return count > 0; } // Mappers private toDomain(data: any): User { return User.reconstitute({ id: UserId.fromString(data.id), email: Email.create(data.email), password: Password.fromHashed(data.password), name: data.name, isActive: data.isActive, createdAt: data.createdAt, updatedAt: data.updatedAt, }); } private toPersistence(user: User): any { return { id: user.id.getValue(), email: user.email.getValue(), password: user.password.getValue(), name: user.name, isActive: user.isActive, createdAt: user.createdAt, updatedAt: user.updatedAt, }; }}4. Presentation Layer
Controllers and HTTP routes.
Controller
// src/presentation/http/controllers/UserController.tsimport { Request, Response } from 'express';import { CreateUserUseCase } from '../../../application/use-cases/CreateUser';import { GetUserUseCase } from '../../../application/use-cases/GetUser';import { UserPresenter } from '../presenters/UserPresenter'; export class UserController { constructor( private readonly createUserUseCase: CreateUserUseCase, private readonly getUserUseCase: GetUserUseCase ) {} async create(req: Request, res: Response): Promise<Response> { const result = await this.createUserUseCase.execute(req.body); if (result.isFailure()) { const error = result.getError(); return res.status(this.getErrorStatus(error)).json({ error: error.message, }); } return res.status(201).json(UserPresenter.toJSON(result.getValue())); } async getById(req: Request, res: Response): Promise<Response> { const result = await this.getUserUseCase.execute({ id: req.params.id }); if (result.isFailure()) { return res.status(404).json({ error: 'User not found' }); } return res.json(UserPresenter.toJSON(result.getValue())); } private getErrorStatus(error: Error): number { switch (error.constructor.name) { case 'EmailAlreadyExistsError': return 409; case 'UserNotFoundError': return 404; case 'ValidationError': case 'DomainError': return 400; default: return 500; } }}Resilience Patterns
Circuit Breaker
// src/shared/patterns/CircuitBreaker.tstype State = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; interface CircuitBreakerOptions { timeout: number; errorThreshold: number; resetTimeout: number; onStateChange?: (from: State, to: State) => void;} export class CircuitBreaker { private state: State = 'CLOSED'; private failures = 0; private successes = 0; private lastFailureTime?: number; private readonly halfOpenMaxAttempts = 3; constructor(private readonly options: CircuitBreakerOptions) {} async execute<T>(fn: () => Promise<T>): Promise<T> { if (this.state === 'OPEN') { if (Date.now() - this.lastFailureTime! > this.options.resetTimeout) { this.transitionTo('HALF_OPEN'); } else { throw new CircuitBreakerOpenError('Circuit breaker is OPEN'); } } try { const result = await Promise.race([ fn(), this.timeout(), ]); this.onSuccess(); return result as T; } catch (error) { this.onFailure(); throw error; } } private timeout(): Promise<never> { return new Promise((_, reject) => { setTimeout( () => reject(new Error('Operation timed out')), this.options.timeout ); }); } private onSuccess(): void { if (this.state === 'HALF_OPEN') { this.successes++; if (this.successes >= this.halfOpenMaxAttempts) { this.transitionTo('CLOSED'); } } else { this.failures = 0; } } private onFailure(): void { this.failures++; this.lastFailureTime = Date.now(); if (this.state === 'HALF_OPEN') { this.transitionTo('OPEN'); } else if (this.failures >= this.options.errorThreshold) { this.transitionTo('OPEN'); } } private transitionTo(newState: State): void { if (this.state !== newState) { this.options.onStateChange?.(this.state, newState); this.state = newState; this.failures = 0; this.successes = 0; } }}Retry with Exponential Backoff and Jitter
// src/shared/patterns/RetryWithBackoff.tsinterface RetryOptions { maxRetries: number; baseDelay: number; maxDelay: number; shouldRetry?: (error: Error) => boolean;} export async function retryWithBackoff<T>( fn: () => Promise<T>, options: RetryOptions): Promise<T> { const { maxRetries, baseDelay, maxDelay, shouldRetry } = options; let lastError: Error; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; if (shouldRetry && !shouldRetry(lastError)) { throw lastError; } if (attempt < maxRetries) { const exponentialDelay = baseDelay * Math.pow(2, attempt); const jitter = Math.random() * 0.3 * exponentialDelay; const delay = Math.min(exponentialDelay + jitter, maxDelay); await new Promise((resolve) => setTimeout(resolve, delay)); } } } throw lastError!;}Containerization with Docker
# docker/DockerfileFROM node:20-alpine AS builder WORKDIR /appCOPY package*.json ./COPY prisma ./prisma/RUN npm ci COPY . .RUN npx prisma generateRUN npm run buildRUN npm prune --production FROM node:20-alpine WORKDIR /app RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 COPY --from=builder --chown=nodejs:nodejs /app/dist ./distCOPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modulesCOPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma ENV NODE_ENV=productionENV PORT=3000 USER nodejs HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health/live || exit 1 EXPOSE 3000CMD ["node", "dist/server.js"]Orchestration with Kubernetes
# k8s/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata: name: user-servicespec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: user-service template: spec: containers: - name: user-service image: myregistry/user-service:latest ports: - containerPort: 3000 resources: requests: memory: "128Mi" cpu: "100m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /health/live port: 3000 initialDelaySeconds: 15 periodSeconds: 10 readinessProbe: httpGet: path: /health/ready port: 3000 initialDelaySeconds: 5 periodSeconds: 5Observability
Health Checks
// src/presentation/http/controllers/HealthController.tsimport { Request, Response } from 'express'; export class HealthController { async live(req: Request, res: Response): Promise<Response> { return res.status(200).json({ status: 'alive', timestamp: new Date().toISOString(), }); } async ready(req: Request, res: Response): Promise<Response> { const checks = await Promise.all([ this.checkDatabase(), this.checkRabbitMQ(), ]); const allHealthy = checks.every((c) => c.status === 'healthy'); return res.status(allHealthy ? 200 : 503).json({ status: allHealthy ? 'ready' : 'not ready', timestamp: new Date().toISOString(), checks, }); }}Production Checklist
Before deploying to production, verify:
Security
- Sensitive variables in secrets (not in code)
- HTTPS enabled
- Rate limiting configured
- Input validation on all routes
- Security headers (Helmet)
- Authentication/Authorization implemented
Performance
- Database connection pooling
- Cache implemented (Redis)
- Optimized queries (indexes)
- Gzip compression enabled
- Structured JSON logs
Resilience
- Circuit breaker for external calls
- Retry with backoff
- Timeouts configured
- Graceful shutdown implemented
- Health checks working
Observability
- Structured logs
- Metrics exposed (Prometheus)
- Distributed tracing (Jaeger/Zipkin)
- Alerts configured
- Monitoring dashboard
Conclusion
Microservices with Node.js, following Clean Architecture, DDD, and SOLID, provide a solid foundation for scalable and maintainable systems. Key points to remember:
- Start with the domain: Model your business first, frameworks later
- Keep layers separated: Dependency Inversion is fundamental
- Tests are mandatory: Especially in the domain layer
- Invest in observability: Logs, metrics, and traces from the start
- Automate everything: CI/CD, infrastructure as code
- Resilience by default: Circuit breaker, retry, timeout
In the next article, learn how to implement an API Gateway with Kong to manage your microservices.
Enjoyed the content? Your contribution helps keep everything online and free!
0737160d-e98f-4a65-8392-5dba70e7ff3e