@sotajs/ddd

Tactical DDD was designed for
a runtime Node doesn't provide

Nominal typing, real encapsulation, rich domain models — DDD patterns assume a Java/C# runtime. In Node you pay for every invariant with manual infrastructure. Most teams cut corners, keeping the vocabulary and losing the guarantees.

Four functions. Zero lock-in. DDD without the infrastructure tax.

01

Structural typing: two strings, same type

TS lets you pass a UserId where a ProductId is expected
without library
type UserId = string;
type ProductId = string;

function assignToUser(id: UserId) { ... }
function getProduct(id: ProductId) { ... }

assignToUser(productId); // compiles. runtime bug.
getProduct(userId);      // compiles. another bug.
with @sotajs/ddd
import { createBrandedId } from '@sotajs/ddd';

const UserId = createBrandedId({
  brand: 'UserId',
  schema: z.string().uuid(),
});
type UserId = ReturnType<typeof UserId.create>;

const ProductId = createBrandedId({
  brand: 'ProductId',
  schema: z.string().uuid(),
});
type ProductId = ReturnType<typeof ProductId.create>;

function assignToUser(id: UserId) {}
function getProduct(id: ProductId) {}

assignToUser(productIdInstance); // TypeScript error
getProduct(userIdInstance);      // TypeScript error

Compile-time safety

UserId and ProductId are distinct types. The compiler catches swaps before they reach production.

Validation on creation

Schema validates the raw string on .create(). UUID format enforced, no silent garbage.

JSON-safe

.toJSON() and .toString() built in. Serializes to plain string, deserializes through .create().

02

Encapsulation: no true privacy in JS

Private fields exist but serialization strips them. ORMs write past every guard.
without library
class User {
  constructor(public bio: string) {}
}
const user = new User('hello');

user.bio = 'anything'; // direct mutation

// ORM saves it. Invariant check? Skipped.
// JSON.stringify(user) — full access.
// Six months later: who changed this?
with @sotajs/ddd
const User = createEntity({
  schema: UserSchema,
  actions: {
    updateBio(state, bio) {
      state.bio = bio;
    },
  },
});

user.actions.updateBio('new bio');  // ✓
user.props.bio = 'x'; // TypeError: frozen
// Every mutation goes through
// a named, typed action — one path,
// grepable, auditable.

WeakMap state storage

State lives outside this. No public fields. No accidental exposure through iteration or logging.

deepFreeze on every read

.props returns a frozen snapshot. Mutation throws TypeError. Not a convention — a physical constraint.

Actions — single path

Every state change has a name, a typed signature, and a single location. Grep actions.updateBio → you found every call site.

03

The cost gap: rich model = 900 lines of boilerplate

Manual DDD is correct but nobody writes it. Anemic models win not by merit but by price.
Manual — 45 lines per entity, never written
class User {
  private bio: string;
  private username: string;

  constructor(props: UserProps) {
    this.bio  = props.bio;
    this.username = props.username;
  }

  updateBio(bio: string) {
    if (bio.length > 500)
      throw new Error("Too long");
    this.bio = bio;
  }
  updateUsername(name: string) {
    if (!name) throw new Error();
    this.username = name;
  }
  setBio(v) { this.bio = v; }
  setUsername(v) { this.username = v; }
  get props() {
    return Object.freeze({...this});
  }
  equals(o) {
    return this.id === o.id;
  }
  // ×20 entities = 900 lines
  // Nobody writes this.
  // They write: interface User {}
}
@sotajs/ddd — 3 fields of config
const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string(),
  bio: z.string().optional(),
});

const User = createEntity({
  schema: UserSchema,
  actions: {
    updateBio(state, bio) {
      state.bio = bio;
    },
    updateUsername(state, n) {
      state.username = n;
    },
  },
  computed: {
    displayName(props) {
      return props.username;
    },
  },
});
// deepFreeze — automatic
// equals() — automatic
// auto-setters — automatic
// type inference — automatic
04

Culture: invariants are optional by default

Node ecosystem thinks in data transformations. Invariants live in if-statements scattered across services.
without library — invariants are optional
// OrderService.ts
async confirmOrder(orderId) {
  const order = await db.orders.find(orderId);
  // Did we check status? Maybe.
  // Is this check in every service
  // that touches Order? Probably not.
  order.status = 'confirmed';
  await db.orders.save(order);
}

// RefundService.ts — different file
async processRefund(orderId) {
  const order = await db.orders.find(orderId);
  // Can we refund a pending order?
  // Same check? Different check?
  // Six months: who knows.
}
with @sotajs/ddd — required
const Order = createAggregate({
  name: 'Order',
  schema: OrderSchema,
  invariants: [
    (props) => {
      if (props.status === 'confirmed'
        && props.amountPaid === 0)
        throw new Error(
          'Cannot confirm unpaid order'
        );
    },
  ],
  actions: {
    confirm(state) {
      state.status = 'confirmed';
    },
  },
});

// Invariants run on .create()
Order.create({ status: 'confirmed', amountPaid: 0 });
// Error: Cannot confirm unpaid order

// AND after every action
order.actions.confirm();
// Error: Cannot confirm unpaid order

Required by TypeScript

invariants is not optional on createAggregate. The config won't compile without it. You cannot accidentally ship an aggregate with no rules.

Checked on every action

Invariants run at .create() and after every action. You don't "remember to call validate." The library does it.

Single source of truth

Change one business rule → change it in one place. Every action and every create picks it up automatically.

The whole picture

One entity. From declaration to use case.

Your Zod schema. Your actions. Your computed properties. The library handles the rest.

Declaration
user-profile.entity.ts
import { z } from 'zod';
import { createEntity } from '@sotajs/ddd';

const UserProfileSchema = z.object({
  id: z.string().uuid(),
  username: z.string(),
  bio: z.string().optional(),
});
type UserProfileProps =
  z.infer<typeof UserProfileSchema>;

export const UserProfile = createEntity({
  schema: UserProfileSchema,
  actions: {
    updateUsername(state, name) {
      state.username = name;
    },
    updateBio(state, bio) {
      state.bio = bio;
    },
  },
  computed: {
    displayName(props) {
      return props.username;
    },
  },
});
export type UserProfile =
  ReturnType<typeof UserProfile.create>;
Usage in a command
update-profile.command.ts
// Schema validates on create
const profile = UserProfile.create({
  id: 'b3f1ae2c-abcd',
  username: 'alice',
});

export const updateProfile = async (
  userId: string, bio: string,
) => {
  const profile = await profiles
    .findById(userId);

  profile.actions.updateBio(bio);
  await profiles.save(profile);

  return {
    id: profile.id,
    displayName: profile.displayName,
  };
};

Your stack

Four functions. Not a framework.

Works inside NestJS — or anything

Your controllers, DI, and modules stay as they are. No decorators, no base classes, no new DI system.

One module at a time

Replace interface Order with createAggregate in one service. The app doesn't notice. Try on Friday.

No new vocabulary

You already write Zod schemas for DTOs. Actions are functions. Computed are getters. The library removes the boilerplate between them.

npm install @sotajs/ddd

TypeScript ^5.0 · Zero dependencies · Works with Zod, ArkType, Valibot · GitHub