What is sotajs/ddd?

sotajs/ddd is a zero-dependency TypeScript library that provides DDD building blocks as enforced constructs, not suggestions.

It gives you four factories: Entity, Aggregate, Value Object, and BrandedId. Each enforces domain rules at runtime — invariants run on every mutation, state is immutable from the outside, and identity vs. structural equality is handled correctly.

Why this exists

The Gap

NestJS — and most Node.js frameworks — give you excellent application structure: modules, dependency injection, controllers, providers.

What they don’t give you is domain correctness enforcement.

In a real system, after 6–12 months:

  • Business rules scatter across services
  • if (order.status !== 'pending')” appears in 7 different files
  • Entity state is mutated from anywhere
  • Domain logic leaks into controllers and infrastructure
  • No single source of truth for what a valid Order looks like

The Solution

sotajs/ddd sits inside your NestJS modules (or any framework) and enforces:

  1. Invariants — checked on creation and after every mutation
  2. Encapsulation — state is mutated only through defined actions
  3. Identity — entities compared by ID, not by value
  4. Boundaries — Aggregates are transactional consistency boundaries

NestJS handles the application boundary. We handle the domain boundary.

Installation

npm install @sotajs/ddd

Requirements: TypeScript ^5.0. No other runtime dependencies.

Your First Entity

import { createEntity } from "@sotajs/ddd";
import { z } from "zod"; // optional — any validator works

const User = createEntity({
  validate: (data) =>
    z.object({
      id: z.string(),
      name: z.string(),
      email: z.string().email().optional(),
    }).parse(data),

  actions: {
    rename: (draft, newName: string) => {
      draft.name = newName;
    },
    setEmail: (draft, email: string) => {
      draft.email = email;
    },
  },
});

const user = User.create({ id: "1", name: "Alice" });

// Read state (frozen — cannot be mutated from outside)
console.log(user.props.name); // "Alice"

// Mutate through actions only
user.actions.rename("Alicia");
console.log(user.props.name); // "Alicia"

// Identity comparison
const sameUser = User.create({ id: "1", name: "Bob" });
console.log(user.equals(sameUser)); // true (same ID)

Your First Aggregate

An Aggregate is a transactional boundary. It enforces invariants, hydrates nested entities, and collects domain events.

import { createAggregate } from "@sotajs/ddd";

const Order = createAggregate({
  name: "Order",
  validate: (data) =>
    z.object({
      id: z.string(),
      items: z.array(z.object({
        productId: z.string(),
        quantity: z.number().min(1),
      })),
    }).parse(data),

  invariants: [
    (props) => {
      if (props.items.length === 0) {
        throw new Error("Order must have at least one item");
      }
    },
  ],

  actions: {
    addItem: (draft, item) => {
      draft.items.push(item);
    },
    ship: (draft) => {
      if (draft.status !== "pending") {
        throw new Error("Only pending orders can be shipped");
      }
      draft.status = "shipped";
      // Return a domain event
      return {
        event: {
          aggregateId: draft.id,
          timestamp: new Date(),
          type: "OrderShipped",
        },
      };
    },
  },

  computed: {
    itemCount: (props) => props.items.length,
    total: (props) => props.items.reduce((s, i) => s + i.quantity, 0),
  },
});

const order = Order.create({
  id: "o1",
  items: [{ productId: "p1", quantity: 2 }],
});

// Invariant: cannot create with zero items
// Order.create({ id: "o2", items: [] }); // throws

order.actions.addItem({ productId: "p2", quantity: 1 });
order.actions.ship();

// Collect domain events
const events = order.getPendingEvents();
// [{ aggregateId: "o1", timestamp: ..., type: "OrderShipped" }]

Design Principles

1. Zero dependencies

No Zod, no Immer, no DI container. You bring your own validator. The library is pure TypeScript with no runtime imports.

2. Enforced, not suggested

Invariants are not documentation. They run at runtime. Violate a business rule → the action throws.

3. Framework-agnostic

Works with NestJS, Express, Fastify, or plain Node.js. It defines domain constructs — it doesn’t care how you wire infrastructure.

4. Bring your own validator

The validate callback receives unknown and returns typed props. Use Zod, Valibot, TypeBox, or write a plain function. The library only cares that you return the right shape or throw.

Next Steps