@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.
Structural typing: two strings, same type
type UserId = string;
type ProductId = string;
function assignToUser(id: UserId) { ... }
function getProduct(id: ProductId) { ... }
assignToUser(productId); // compiles. runtime bug.
getProduct(userId); // compiles. another bug. 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().
Encapsulation: no true privacy in JS
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? 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.
The cost gap: rich model = 900 lines of boilerplate
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 {}
} 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 Culture: invariants are optional by default
// 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.
} 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.
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>; // 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