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:
- Invariants — checked on creation and after every mutation
- Encapsulation — state is mutated only through defined actions
- Identity — entities compared by ID, not by value
- 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
- Entity API — full reference
- Aggregate API — transactions, invariants, events
- Value Object API — structural equality
- BrandedId API — typed identity