7 min read

Domain‑Driven Design in TypeScript: Building Robust NestJS Back‑ends and Next.js Front‑ends

Domain‑Driven Design in TypeScript: Building Robust NestJS Back‑ends and Next.js Front‑ends

Introduction

Modern JavaScript/TypeScript stacks often blur the line between front‑end and back‑end code. While this flexibility is a strength, it can also lead to tangled business logic that is hard to reason about, test, or evolve. Domain‑Driven Design (DDD) offers a disciplined way to keep the domain—the core business rules—clean and isolated, regardless of whether the code runs on a NestJS server or a Next.js client.

In this article we’ll explore how to apply DDD concepts in a TypeScript monorepo that hosts a NestJS API and a Next.js UI. You’ll walk away with a concrete folder layout, sample entities, value objects, repositories, and a strategy for sharing the domain model between the two frameworks.

TL;DR – Define your domain in pure TypeScript, keep it framework‑agnostic, and let NestJS and Next.js act as adapters that translate HTTP, GraphQL, or RPC calls into domain operations.

1. Core DDD Building Blocks in TypeScript

DDD Concept Purpose Typical TypeScript Representation
Entity Has a persistent identity that runs through its lifecycle. class Order { constructor(public readonly id: OrderId, ...) {} }
Value Object Immutable, defined only by its attributes. class Money { constructor(public readonly amount: number, public readonly currency: string) {} }
Aggregate Cluster of related entities/value objects with a root that enforces invariants. class OrderAggregate { private readonly items: OrderItem[]; ... }
Repository Collection‑like interface for persisting/retrieving aggregates. `interface OrderRepository { findById(id: OrderId): Promise<OrderAggregate
Domain Service Stateless operation that doesn’t belong to a single entity. class PricingService { calculate(order: OrderAggregate): Money { … } }
Application Service Orchestrates use‑cases, coordinates repositories and domain services. class PlaceOrderUseCase { constructor(private repo: OrderRepository, private pricing: PricingService) {} async execute(cmd: PlaceOrderCmd) { … } }

All of these can be written in plain TypeScript without any NestJS or Next.js decorators, making them reusable across the stack.


2. Project Layout

/libs
  /domain
    /order
      - Order.ts
      - OrderId.ts
      - Money.ts
      - OrderItem.ts
      - OrderRepository.ts
      - PricingService.ts
      - PlaceOrderUseCase.ts
  /shared
    - Result.ts   // functional error handling
/apps
  /api   (NestJS)
    - main.ts
    - order
      - order.controller.ts
      - order.module.ts
      - infra
        - order.prisma.repository.ts
  /web   (Next.js)
    - pages
      - orders
        - index.tsx
        - [id].tsx
    - lib
      - api
        - order.client.ts   // thin wrapper around fetch
  • libs/domain holds the pure domain model.
  • libs/shared contains cross‑cutting utilities (Result/Either, validation).
  • apps/api is the NestJS microservice that implements the driving adapters (HTTP, GraphQL).
  • apps/web is the Next.js front‑end that consumes the domain through a thin API client.

Because the domain lives in a separate library, both NestJS and Next.js can import it directly, guaranteeing that the same business rules are applied everywhere.


3. Defining the Domain – A Mini Order Management Example

3.1 Value Object: Money

// libs/domain/order/Money.ts
export class Money {
  constructor(public readonly amount: number, public readonly currency: string) {
    if (amount < 0) throw new Error('Amount cannot be negative');
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amount + other.amount, this.currency);
  }

  private assertSameCurrency(other: Money) {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch');
    }
  }
}

3.2 Entity: OrderItem

// libs/domain/order/OrderItem.ts
import { Money } from './Money';
import { ProductId } from '../product/ProductId';

export class OrderItem {
  constructor(
    public readonly productId: ProductId,
    public readonly quantity: number,
    public readonly price: Money,
  ) {
    if (quantity <= 0) throw new Error('Quantity must be > 0');
  }

  total(): Money {
    return new Money(this.price.amount * this.quantity, this.price.currency);
  }
}

3.3 Aggregate Root: Order

// libs/domain/order/Order.ts
import { OrderId } from './OrderId';
import { OrderItem } from './OrderItem';
import { Money } from './Money';
import { Result } from '../../shared/Result';

export class Order {
  private items: OrderItem[] = [];

  private constructor(public readonly id: OrderId, private status: 'draft' | 'placed') {}

  static create(id: OrderId): Order {
    return new Order(id, 'draft');
  }

  addItem(item: OrderItem): Result<void> {
    // Business rule: no duplicate product lines
    if (this.items.find(i => i.productId.equals(item.productId))) {
      return Result.fail('Product already added');
    }
    this.items.push(item);
    return Result.ok();
  }

  total(): Money {
    return this.items.reduce((acc, i) => acc.add(i.total()), new Money(0, 'USD'));
  }

  place(): Result<void> {
    if (this.items.length === 0) return Result.fail('Cannot place empty order');
    this.status = 'placed';
    return Result.ok();
  }

  // getters …
}

3.4 Repository Interface

// libs/domain/order/OrderRepository.ts
import { Order } from './Order';
import { OrderId } from './OrderId';

export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

4. NestJS – The Driving Adapter

4.1 Wiring the Repository

// apps/api/src/order/infra/order.prisma.repository.ts
import { Injectable } from '@nestjs/common';
import { OrderRepository } from '@myorg/domain/order/OrderRepository';
import { Order } from '@myorg/domain/order/Order';
import { PrismaService } from '../../prisma.service';
import { OrderId } from '@myorg/domain/order/OrderId';

@Injectable()
export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({ where: { id: id.value } });
    if (!data) return null;
    // map DB rows → domain objects (omitted for brevity)
    const order = Order.create(new OrderId(data.id));
    // populate items, status, etc.
    return order;
  }

  async save(order: Order): Promise<void> {
    // Convert domain → persistence model
    await this.prisma.order.upsert({
      where: { id: order.id.value },
      create: { /* … */ },
      update: { /* … */ },
    });
  }
}

4.2 Application Service as a NestJS Provider

// apps/api/src/order/place-order.usecase.ts
import { Injectable } from '@nestjs/common';
import { OrderRepository } from '@myorg/domain/order/OrderRepository';
import { PricingService } from '@myorg/domain/order/PricingService';
import { PlaceOrderCmd } from './PlaceOrderCmd';

@Injectable()
export class PlaceOrderUseCase {
  constructor(
    private readonly repo: OrderRepository,
    private readonly pricing: PricingService,
  ) {}

  async execute(cmd: PlaceOrderCmd) {
    const order = Order.create(cmd.orderId);
    for (const line of cmd.lines) {
      const item = new OrderItem(line.productId, line.qty, line.price);
      const result = order.addItem(item);
      if (result.isFailure) throw new Error(result.error);
    }
    order.place();
    await this.repo.save(order);
    return order;
  }
}

4.3 Controller – HTTP Endpoint

// apps/api/src/order/order.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { PlaceOrderUseCase } from './place-order.usecase';
import { PlaceOrderDto } from './dto/place-order.dto';

@Controller('orders')
export class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  @Post()
  async place(@Body() dto: PlaceOrderDto) {
    const cmd = {
      orderId: dto.id,
      lines: dto.lines.map(l => ({
        productId: l.productId,
        qty: l.qty,
        price: new Money(l.amount, l.currency),
      })),
    };
    const order = await this.placeOrder.execute(cmd);
    return { id: order.id.value, status: order['status'] };
  }
}

The controller does no business logic; it merely translates the HTTP request into a command object and forwards it to the use‑case.


5. Next.js – The Driven Adapter

5.1 Thin API Client

// apps/web/lib/api/order.client.ts
export async function placeOrder(payload: {
  id: string;
  lines: { productId: string; qty: number; amount: number; currency: string }[];
}) {
  const res = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    const err = await res.json();
    throw new Error(err.message);
  }
  return res.json(); // { id, status }
}

5.2 Page Component Using the Domain Model

// apps/web/pages/orders/new.tsx
import { useState } from 'react';
import { placeOrder } from '../../lib/api/order.client';
import { Money } from '@myorg/domain/order/Money';

export default function NewOrder() {
  const [productId, setProductId] = useState('');
  const [qty, setQty] = useState(1);
  const [amount, setAmount] = useState(0);
  const [currency, setCurrency] = useState('USD');

  const submit = async () => {
    const line = {
      productId,
      qty,
      amount,
      currency,
    };
    try {
      const result = await placeOrder({ id: crypto.randomUUID(), lines: [line] });
      alert(`Order placed – ${result.id}`);
    } catch (e) {
      alert(e instanceof Error ? e.message : 'Unexpected error');
    }
  };

  return (
    <div>
      <h1>New Order</h1>
      {/* simple form omitted for brevity */}
      <button onClick={submit}>Place Order</button>
    </div>
  );
}

Even though the UI lives in a React component, the Money value object is imported directly from the domain library, guaranteeing that the same validation rules (e.g., non‑negative amount) are enforced client‑side.


6. Testing the Domain in Isolation

Because the domain layer has no external dependencies, unit tests are straightforward:

// libs/domain/order/__tests__/order.test.ts
import { Order } from '../Order';
import { OrderId } from '../OrderId';
import { Money } from '../Money';
import { OrderItem } from '../OrderItem';

test('cannot place empty order', () => {
  const order = Order.create(new OrderId('o-1'));
  const result = order.place();
  expect(result.isFailure).toBe(true);
  expect(result.error).toBe('Cannot place empty order');
});

test('adds item and computes total', () => {
  const order = Order.create(new OrderId('o-2'));
  const item = new OrderItem('p-1', 2, new Money(10, 'USD'));
  order.addItem(item);
  expect(order.total().amount).toBe(20);
});

Running these tests against the same code that powers both NestJS and Next.js guarantees consistency across the whole system.


7. Migration Tips & Common Pitfalls

Pitfall How to Avoid
Leaking framework types into the domain (e.g., Request in an entity) Keep the domain folder free of any @nestjs/* or next/* imports. Use plain TypeScript interfaces for data transfer objects.
Duplicated validation (client validates, server validates again) Centralize validation inside value objects and domain services. UI can still perform UX‑friendly checks, but the source of truth stays in the domain.
Large monolithic aggregates Follow the single responsibility principle: if an aggregate grows beyond 200 lines, consider splitting it into bounded contexts.
Hard‑coded persistence Depend on abstractions (OrderRepository) and provide separate implementations for Prisma, TypeORM, in‑memory, or even a mock for tests.
Circular imports between domain and infra Use the dependency inversion principle: domain depends only on abstractions, infra depends on domain. Keep the direction of imports one‑way.

8. Benefits Recap

  1. Single source of truth – Business rules live once, in pure TypeScript.
  2. Framework agnostic – The same domain can be consumed by REST, GraphQL, gRPC, or even a CLI.
  3. Testability – No need for NestJS or Next.js bootstrapping to verify core logic.
  4. Clear boundaries – Teams can own the API layer (NestJS) and the UI layer (Next.js) independently while sharing the domain contract.
  5. Future‑proof – Adding a mobile client (React Native) or a background worker only requires a thin adapter; the domain stays untouched.

Conclusion

Domain‑Driven Design is often associated with heavyweight Java or .NET ecosystems, but its principles translate beautifully to a TypeScript monorepo. By extracting the domain into a framework‑neutral library and treating NestJS and Next.js as adapters, you gain a clean separation of concerns, robust unit testing, and a codebase that scales with business complexity.

Give it a try: start with a small bounded context (like the Order example above), move the code into libs/domain, and gradually replace ad‑hoc business logic scattered across controllers and pages with well‑structured aggregates and services. The payoff is a system where the what (business rules) is unmistakably distinct from the how (HTTP, React, Prisma), making future changes less risky and more enjoyable.

Happy coding!