Siddhant Deval
Siddhant Deval
backend5 min read

Production Capstone: Assembling the Full E-Commerce Billing & Subscription Engine

Synthesizing all 13 preceding architectural layers — Domain Entities, Aggregates, Domain Events, Use Cases, Ports & Adapters, CQRS, and the full Prisma persistence stack — into a production-deployed Node.js e-commerce billing engine proves that Clean Architecture and Domain-Driven Design produce a system that is resilient, independently testable, and infinitely extensible.

Series·Part 14 of 14

Backend Clean Architecture & Domain-Driven Design

Production Capstone: Assembling the Clean OOP Billing Engine

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. Across 13 articles, we built a billing engine where this is structurally guaranteed: the Domain layer enforces invariants, the Application layer orchestrates without containing rules, the Infrastructure layer adapts without leaking types, and the Interface layer delivers without owning business logic. The Dependency Rule is enforced by the TypeScript compiler, the Outbox Pattern guarantees event delivery, and the full test suite of 249 behaviors runs in under 9 seconds.

This capstone article assembles everything into a production-ready service: structured logging, health checks, graceful shutdown, Dockerfile, Docker Compose, and CI pipeline. It then reviews every architectural decision across the series — not as a summary, but as a principled retrospective that answers why each decision was made and what the alternatives cost.


1. Structured Logging Across the Four Layers

Logging in a layered architecture must respect the Dependency Rule — the Domain layer cannot import a logger. Instead, logging belongs at the Application and Infrastructure layers, where behavior that warrants a log entry actually occurs.

1.1 The Logger Port

TYPESCRIPT
// src/domain/shared/ports/ILogger.ts
export interface ILogger {
  info(message: string, context?: Record<string, unknown>): void;
  warn(message: string, context?: Record<string, unknown>): void;
  error(message: string, error?: Error, context?: Record<string, unknown>): void;
  debug(message: string, context?: Record<string, unknown>): void;
}
TYPESCRIPT
// src/infrastructure/logging/PinoLogger.ts
import pino from 'pino';
import { injectable } from 'inversify';
import { ILogger } from '../../domain/shared/ports/ILogger';

@injectable()
export class PinoLogger implements ILogger {
  private readonly logger = pino({
    level: process.env.LOG_LEVEL ?? 'info',
    transport: process.env.NODE_ENV === 'development'
      ? { target: 'pino-pretty' }
      : undefined, // JSON in production — consumed by Datadog/CloudWatch
  });

  info(message: string, context?: Record<string, unknown>): void {
    this.logger.info(context ?? {}, message);
  }

  warn(message: string, context?: Record<string, unknown>): void {
    this.logger.warn(context ?? {}, message);
  }

  error(message: string, error?: Error, context?: Record<string, unknown>): void {
    this.logger.error({ err: error, ...context }, message);
  }

  debug(message: string, context?: Record<string, unknown>): void {
    this.logger.debug(context ?? {}, message);
  }
}

1.2 Logging in the Use Case

TYPESCRIPT
// src/application/order/PlaceOrderUseCase.ts (with logging)
export class PlaceOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly customers: ICustomerRepository,
    private readonly discountService: DiscountCalculationService,
    private readonly discountStrategies: IDiscountStrategy[],
    private readonly eventBus: IEventBus,
    private readonly logger: ILogger,  // Injected via DI
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    this.logger.info('PlaceOrder started', { customerId: command.customerId });
    try {
      const orderId = await this.attemptExecution(command);
      this.logger.info('PlaceOrder succeeded', { customerId: command.customerId, orderId });
      return orderId;
    } catch (err) {
      if (err instanceof DomainError) {
        this.logger.warn('PlaceOrder rejected by domain', { reason: err.message, customerId: command.customerId });
      } else {
        this.logger.error('PlaceOrder failed unexpectedly', err as Error, { customerId: command.customerId });
      }
      throw err;
    }
  }
}

2. Health Checks

A production service must expose health check endpoints for the load balancer and orchestrator (Kubernetes readinessProbe / livenessProbe):

TYPESCRIPT
// src/interface/http/HealthController.ts
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';

export class HealthController {
  constructor(private readonly prisma: PrismaClient) {}

  /** Liveness: is the process running? */
  async live(_req: Request, res: Response): Promise<void> {
    res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
  }

  /** Readiness: can the process serve requests? (Checks all dependencies) */
  async ready(_req: Request, res: Response): Promise<void> {
    const checks = await Promise.allSettled([
      this.checkDatabase(),
    ]);

    const results = {
      database: checks[0].status === 'fulfilled' ? 'ok' : 'error',
    };

    const allHealthy = Object.values(results).every(v => v === 'ok');
    res.status(allHealthy ? 200 : 503).json({ status: allHealthy ? 'ready' : 'degraded', checks: results });
  }

  private async checkDatabase(): Promise<void> {
    await this.prisma.$queryRaw`SELECT 1`;
  }
}
TYPESCRIPT
// Route registration
app.get('/health/live',  (req, res) => healthController.live(req, res));
app.get('/health/ready', (req, res) => healthController.ready(req, res));

3. Graceful Shutdown

A billing service must not drop in-flight requests when a new deployment rolls out. Graceful shutdown drains existing requests before exiting:

TYPESCRIPT
// src/main.ts — graceful shutdown
async function bootstrap(): Promise<void> {
  const container = await buildProductionContainer();
  const prisma    = container.get<PrismaClient>(TYPES.PrismaClient);
  const logger    = container.get<ILogger>(TYPES.ILogger);

  const app    = buildApp(container);
  const server = app.listen(process.env.PORT ?? 3000, () => {
    logger.info('Billing engine started', { port: process.env.PORT ?? 3000 });
  });

  // Start the Outbox poller
  container.get<OutboxPublisher>(TYPES.OutboxPublisher).start();

  const shutdown = async (signal: string) => {
    logger.info(`${signal} received — starting graceful shutdown`);

    // Stop accepting new connections
    server.close(async () => {
      logger.info('HTTP server closed');
      await prisma.$disconnect();
      logger.info('Database disconnected — shutdown complete');
      process.exit(0);
    });

    // Force shutdown after 30s if in-flight requests don't drain
    setTimeout(() => {
      logger.error('Forced shutdown after 30s timeout');
      process.exit(1);
    }, 30_000);
  };

  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT',  () => shutdown('SIGINT'));
}

4. Dockerfile: Multi-Stage Production Build

DOCKERFILE
# Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM base AS builder
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build  # tsc --build

FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist         ./dist
COPY --from=builder /app/prisma       ./prisma
COPY package.json .

USER node  # Run as non-root

EXPOSE 3000
CMD ["node", "dist/main.js"]

The multi-stage build produces a minimal image that contains only the compiled JavaScript, the production node_modules, and the Prisma schema — no TypeScript compiler, no test files, no development dependencies.


5. Docker Compose for Development

YAML
# docker-compose.yml
version: '3.9'

services:
  billing-engine:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://billing:billing@postgres:5432/billing
      STRIPE_KEY:   ${STRIPE_KEY}
      REDIS_URL:    redis://redis:6379
      LOG_LEVEL:    debug
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./src:/app/src  # Hot reload in development

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER:     billing
      POSTGRES_PASSWORD: billing
      POSTGRES_DB:       billing
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U billing"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

6. CI Pipeline

YAML
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main]

jobs:
  lint-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      # Verify architectural boundary constraint
      - name: Check no infrastructure imports in domain
        run: |
          ! grep -r "from.*infrastructure" src/domain/
          ! grep -r "from.*@prisma" src/domain/
          ! grep -r "from.*stripe" src/application/

      # TypeScript project references — enforces layer boundaries at compile time
      - run: npx tsc --build --force

      # Fast unit tests (no Docker needed)
      - run: npx vitest run --reporter=verbose

      # Coverage gates
      - run: npx vitest run --coverage

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: billing
          POSTGRES_PASSWORD: billing
          POSTGRES_DB: billing
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://billing:billing@localhost:5432/billing
      - run: npx vitest run --config vitest.integration.config.ts
        env:
          DATABASE_URL: postgresql://billing:billing@localhost:5432/billing

7. The Architectural Retrospective: Every Decision and Its Cost

Decision Alternative Considered Why This Decision Cost
Four-layer Clean Architecture Three-tier (controller/service/repository) Forces the Dependency Rule at a structural level; makes each layer independently testable Extra boilerplate; steeper learning curve for teams new to DDD
Domain Entities with private constructors Plain TypeScript interfaces Constructor factories enforce invariants at creation time; illegal states are unrepresentable More verbose than POJOs; requires reconstitute() factory for persistence
Branded primitives for IDs string everywhere Cross-ID assignment is a compile-time error; catches the most common billing bug class Requires factory functions; less ergonomic in test data construction
Integer-cent Money number for amounts Eliminates floating-point precision errors in all arithmetic Conversion overhead; less readable in logs (4999 vs 49.99)
IOrderRepository in the Domain layer Repository in Application layer Interface ownership determines the dependency direction; Domain owns what it needs Slightly unintuitive placement for engineers from the three-tier world
Collect-then-dispatch Domain Events Synchronous dispatch inside domain methods Events dispatched after DB commit → no phantom events for rolled-back transactions Eventual consistency — handlers may lag; at-least-once requires idempotency
Outbox Pattern Direct event bus publish post-commit Eliminates the crash-between-commit-and-publish data loss gap Extra outbox_events table; background poller process; complexity
TypeScript project references ESLint rules / runtime checks Compiler enforces layer boundaries — violation is a build failure, not a review comment Initial setup complexity; tsc --build required in CI
InversifyJS for DI Manual wiring in main.ts Declarative binding registry; scope management; test container with one-line swaps Decorator metadata (reflect-metadata); transpiler configuration
CQRS read model Single Aggregate for reads and writes Read queries are O(1) indexed SELECT; no Aggregate instantiation overhead Eventual consistency between write and read models; projector complexity
Repository contract tests Mock-based unit tests Both InMemory and Prisma must pass the same 12 assertions — prevents silent contract violations Extra test infrastructure; shared suite maintenance

8. Where to Go Next

The billing engine built across this series is production-ready for a single-service deployment. The natural next steps for a real-world billing system:

8.1 Saga Pattern for Long-Running Workflows

A PlaceOrder that includes payment, inventory reservation, and shipping scheduling spans multiple external systems. If payment succeeds but inventory reservation fails, the order needs to be cancelled and the payment refunded. This is a Saga — a sequence of local transactions coordinated by compensation events. The infrastructure built here (Domain Events + Outbox Pattern) is the foundation for implementing Sagas.

8.2 Event Store for Full Audit Trail

The Outbox Pattern stores events transiently — they are deleted after publication. An Event Store (e.g., EventStoreDB or a custom append-only events table) retains every event permanently. This enables full audit logs, temporal queries ("what was the state of Order X at timestamp T?"), and event replay to rebuild read models from scratch after schema changes.

8.3 Kafka for Cross-Service Events

The InMemoryEventBus dispatches events within the same process. In a microservices architecture, OrderPlaced must be consumed by the Inventory service and the Notification service — separate deployments. Replacing InMemoryEventBus with a Kafka adapter (at the IEventBus binding in the DI container) requires zero changes to the domain or application layers.

8.4 GraphQL API Layer

Adding a GraphQL API is a new driving adapter in src/interface/graphql/. The resolvers call the same Use Cases with the same Command DTOs. The Application and Domain layers are unchanged — this is the Hexagonal Architecture benefit from Part 7 in full effect.


9. The Series at a Glance


Summary

The billing engine implements one principle, end-to-end: dependencies point inward. The Domain layer has no outward dependencies. The Application layer depends only on the Domain. Infrastructure implements Domain interfaces. The Interface layer calls Application use cases.

Every design decision in the series — private constructors, branded primitives, integer-cent Money, repository ports in the domain layer, collect-then-dispatch events, the Outbox Pattern, TypeScript project references — is a direct consequence of enforcing this single rule at every layer boundary.

The test suite validates 249 behaviors in 8.4 seconds. The architecture allows swapping Stripe for PayPal, Prisma for MongoDB, or PostgreSQL for a local Map — each swap is one binding in one container module, zero changes to Domain or Application.

That is what it means for domain objects to be autonomous state machines with enforced invariants. The architecture makes it structurally true — not aspirationally true.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Clean Architecture#Domain-Driven Design#Node.js#TypeScript#Capstone#E-Commerce#Production
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.