Skip to main content

Overview

@contract-kit/domain provides framework-agnostic helpers for domain-driven design patterns. It includes builders for value objects, entities, and domain events that work with any Standard Schema library (Zod, Valibot, ArkType, etc.).
Prefer the contract-kit meta package for new projects. It re-exports @contract-kit/domain along with core, client, application, and more.

Installation

npm install @contract-kit/domain zod
This package works with any Standard Schema library. Examples use Zod, but you can use Valibot, ArkType, or any compatible library.

Features

  • Value Objects - Immutable, validated types that represent domain concepts
  • Entities - Domain objects with identity and behavior
  • Domain Events - Event definitions with payload validation
  • Type Safety - Full TypeScript support with branded types
  • Immutability - All domain objects are immutable by default
  • Validation - Built-in schema validation using Standard Schema

Value Objects

Value objects are immutable, validated types that represent domain concepts. They ensure data validity and provide type safety through branded types.

Simple Value Objects

import { valueObject } from "@contract-kit/domain";
import { z } from "zod";

// Define an Email value object
const Email = valueObject("Email")
  .schema(z.string().email())
  .brand();

type Email = typeof Email.Type;

// Create with validation
const email = await Email.create("[email protected]"); 
// Returns branded Email type

// Check validity without throwing
const isValid = await Email.isValid("invalid"); // false

// Access raw value
console.log(email); // "[email protected]"

Complex Value Objects

Value objects can represent complex domain concepts:
const Money = valueObject("Money")
  .schema(z.object({
    amount: z.number().positive(),
    currency: z.enum(["USD", "EUR", "GBP"]),
  }))
  .brand();

type Money = typeof Money.Type;

const price = await Money.create({ 
  amount: 99.99, 
  currency: "USD" 
});

console.log(price.amount);    // 99.99
console.log(price.currency);  // "USD"

Value Objects with Transformation

Use transformations to normalize values:
const Slug = valueObject("Slug")
  .schema(
    z.string()
      .min(1)
      .transform((s) => s.toLowerCase().replace(/\s+/g, "-"))
  )
  .brand();

type Slug = typeof Slug.Type;

const slug = await Slug.create("Hello World"); // "hello-world"

More Value Object Examples

// UUID identifier
const UserId = valueObject("UserId")
  .schema(z.string().uuid())
  .brand();

// Positive integer
const Quantity = valueObject("Quantity")
  .schema(z.number().int().positive())
  .brand();

// Date range
const DateRange = valueObject("DateRange")
  .schema(z.object({
    start: z.date(),
    end: z.date(),
  }).refine(
    (data) => data.end > data.start,
    "End date must be after start date"
  ))
  .brand();

// Percentage (0-100)
const Percentage = valueObject("Percentage")
  .schema(z.number().min(0).max(100))
  .brand();

Entities

Entities are domain objects with identity and behavior. They’re immutable and provide methods for domain operations.

Basic Entity

import { entity } from "@contract-kit/domain";
import { z } from "zod";

const Todo = entity("Todo")
  .props(z.object({
    id: z.string(),
    title: z.string(),
    description: z.string().optional(),
    completed: z.boolean().default(false),
    createdAt: z.date().default(() => new Date()),
  }))
  .methods((self) => ({
    complete() {
      return self.with({ completed: true });
    },
    rename(title: string) {
      return self.with({ title });
    },
    isOverdue(now: Date) {
      // Read-only method
      return !self.props.completed && self.props.createdAt < now;
    },
  }))
  .build();

type Todo = typeof Todo.Type;

Using Entities

// Create a new entity
const todo = Todo.create({
  id: "1",
  title: "Learn Contract Kit",
});

// Access properties
console.log(todo.id);        // "1"
console.log(todo.title);     // "Learn Contract Kit"
console.log(todo.completed); // false

// Call methods (returns new instance - immutable)
const completed = todo.complete();
console.log(todo.completed);      // false (original unchanged)
console.log(completed.completed); // true

// Chain method calls
const renamed = todo
  .rename("Master Contract Kit")
  .complete();

console.log(renamed.title);     // "Master Contract Kit"
console.log(renamed.completed); // true

Entity with Validation

Entities validate their properties on creation:
const User = entity("User")
  .props(z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    name: z.string().min(1),
    role: z.enum(["user", "admin"]).default("user"),
    createdAt: z.date().default(() => new Date()),
  }))
  .methods((self) => ({
    promote() {
      if (self.props.role === "admin") {
        throw new Error("Already an admin");
      }
      return self.with({ role: "admin" as const });
    },
    demote() {
      if (self.props.role === "user") {
        throw new Error("Already a user");
      }
      return self.with({ role: "user" as const });
    },
    rename(name: string) {
      if (name.length === 0) {
        throw new Error("Name cannot be empty");
      }
      return self.with({ name });
    },
  }))
  .build();

type User = typeof User.Type;

Rich Domain Model

Encapsulate business logic in entity methods:
const Account = entity("Account")
  .props(z.object({
    id: z.string(),
    balance: z.number(),
    status: z.enum(["active", "frozen", "closed"]),
  }))
  .methods((self) => ({
    deposit(amount: number) {
      if (self.props.status !== "active") {
        throw new Error("Account is not active");
      }
      if (amount <= 0) {
        throw new Error("Deposit amount must be positive");
      }
      return self.with({
        balance: self.props.balance + amount,
      });
    },
    withdraw(amount: number) {
      if (self.props.status !== "active") {
        throw new Error("Account is not active");
      }
      if (amount <= 0) {
        throw new Error("Withdrawal amount must be positive");
      }
      if (self.props.balance < amount) {
        throw new Error("Insufficient funds");
      }
      return self.with({
        balance: self.props.balance - amount,
      });
    },
    freeze() {
      return self.with({ status: "frozen" as const });
    },
    close() {
      if (self.props.balance !== 0) {
        throw new Error("Cannot close account with non-zero balance");
      }
      return self.with({ status: "closed" as const });
    },
  }))
  .build();

type Account = typeof Account.Type;

Domain Events

Domain events represent something that happened in your domain. They’re immutable and validated using schemas.

Defining Domain Events

import { domainEvent } from "@contract-kit/domain";
import { z } from "zod";

const TodoCompleted = domainEvent(
  "todo.completed",
  z.object({
    todoId: z.string(),
    completedAt: z.string().datetime(),
    completedBy: z.string(),
  })
);

type TodoCompletedEvent = typeof TodoCompleted;

// Create an event instance
const event = {
  type: "todo.completed",
  payload: {
    todoId: "123",
    completedAt: new Date().toISOString(),
    completedBy: "user-1",
  },
};

Multiple Event Types

// User domain events
const UserCreated = domainEvent(
  "user.created",
  z.object({
    userId: z.string(),
    email: z.string(),
    createdAt: z.string().datetime(),
  })
);

const UserDeleted = domainEvent(
  "user.deleted",
  z.object({
    userId: z.string(),
    deletedAt: z.string().datetime(),
    deletedBy: z.string(),
  })
);

// Order domain events
const OrderPlaced = domainEvent(
  "order.placed",
  z.object({
    orderId: z.string(),
    customerId: z.string(),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number(),
      price: z.number(),
    })),
    total: z.number(),
    placedAt: z.string().datetime(),
  })
);

const OrderShipped = domainEvent(
  "order.shipped",
  z.object({
    orderId: z.string(),
    trackingNumber: z.string(),
    shippedAt: z.string().datetime(),
  })
);

Using Domain Events with Use Cases

Domain events integrate with use cases from @contract-kit/application:
import { createUseCaseFactory } from "@contract-kit/application";
import { TodoCompleted } from "@/domain/events";

const useCase = createUseCaseFactory<AppCtx>();

const completeTodo = useCase
  .command("todos.complete")
  .input(z.object({ id: z.string() }))
  .output(z.object({ success: z.boolean() }))
  .emits([TodoCompleted]) // Declares event emission
  .run(async ({ ctx, input }) => {
    const todo = await ctx.ports.db.todos.complete(input.id);
    
    if (!todo) {
      throw err.appError("TodoNotFound");
    }
    
    // Publish the domain event
    await ctx.ports.eventBus.publish({
      type: "todo.completed",
      payload: {
        todoId: todo.id,
        completedAt: new Date().toISOString(),
        completedBy: ctx.user.id,
      },
    });

    return { success: true };
  });

Patterns

Aggregate Roots

Use entities as aggregate roots with child entities:
const OrderItem = entity("OrderItem")
  .props(z.object({
    productId: z.string(),
    quantity: z.number().positive(),
    price: z.number().positive(),
  }))
  .methods((self) => ({
    total() {
      return self.props.quantity * self.props.price;
    },
  }))
  .build();

// Schema for order items (matches OrderItem entity structure)
const OrderItemSchema = z.object({
  productId: z.string(),
  quantity: z.number().positive(),
  price: z.number().positive(),
});

type OrderItemData = z.infer<typeof OrderItemSchema>;

const Order = entity("Order")
  .props(z.object({
    id: z.string(),
    customerId: z.string(),
    items: z.array(OrderItemSchema),
    status: z.enum(["draft", "placed", "shipped", "delivered"]),
    createdAt: z.date().default(() => new Date()),
  }))
  .methods((self) => ({
    addItem(item: OrderItemData) {
      return self.with({
        items: [...self.props.items, item],
      });
    },
    removeItem(productId: string) {
      return self.with({
        items: self.props.items.filter((i) => i.productId !== productId),
      });
    },
    place() {
      if (self.props.items.length === 0) {
        throw new Error("Cannot place empty order");
      }
      if (self.props.status !== "draft") {
        throw new Error("Order has already been placed");
      }
      return self.with({ status: "placed" as const });
    },
    total() {
      return self.props.items.reduce((sum, item) => {
        return sum + (item.quantity * item.price);
      }, 0);
    },
  }))
  .build();

type Order = typeof Order.Type;

Value Objects in Entities

Combine value objects and entities for rich domain models:
const EmailAddress = valueObject("EmailAddress")
  .schema(z.string().email())
  .brand();

const Customer = entity("Customer")
  .props(z.object({
    id: z.string(),
    email: z.string().email(), // Validated by schema
    name: z.string(),
    verified: z.boolean().default(false),
  }))
  .methods((self) => ({
    verify() {
      return self.with({ verified: true });
    },
    updateEmail(email: typeof EmailAddress.Type) {
      return self.with({ 
        email: email as unknown as string,
        verified: false, // Reset verification on email change
      });
    },
  }))
  .build();

Repository Pattern

Use entities with repository adapters:
// ports/repositories.ts
interface TodoRepository {
  findById(id: string): Promise<typeof Todo.Type | null>;
  save(todo: typeof Todo.Type): Promise<void>;
  delete(id: string): Promise<void>;
  list(): Promise<Array<typeof Todo.Type>>;
}

// application/todos/complete.ts
const completeTodo = useCase
  .command("todos.complete")
  .input(z.object({ id: z.string() }))
  .output(z.object({ success: z.boolean() }))
  .run(async ({ ctx, input }) => {
    // Load entity from repository
    const todo = await ctx.ports.todos.findById(input.id);
    
    if (!todo) {
      throw err.appError("TodoNotFound");
    }
    
    // Use entity method for domain logic
    const completed = todo.complete();
    
    // Save updated entity
    await ctx.ports.todos.save(completed);
    
    return { success: true };
  });

API Reference

valueObject(name)

Creates a value object builder.
const Email = valueObject(name: string)
  .schema(schema: StandardSchema)
  .brand();
Value Object Definition:
type ValueObjectDef = {
  name: string;
  Type: BrandedType;
  create: (value: unknown) => Promise<BrandedType>;
  isValid: (value: unknown) => Promise<boolean>;
};

entity(name)

Creates an entity builder.
const Todo = entity(name: string)
  .props(schema: StandardSchema)
  .methods((self) => ({
    methodName() { return self.with({ ... }); },
  }))
  .build();
Entity Instance:
type EntityInstance = {
  // All props are accessible as properties
  [key: string]: value;
  
  // Methods defined in .methods()
  methodName(): EntityInstance;
  
  // Built-in method for updates
  with(updates: Partial<Props>): EntityInstance;
};
Entity Definition:
type EntityDef = {
  name: string;
  Type: EntityInstance;
  create: (props: Props) => EntityInstance;
};

domainEvent(type, payloadSchema)

Creates a domain event definition.
const Event = domainEvent(
  type: string,
  payloadSchema: StandardSchema
);
Domain Event Definition:
type DomainEventDef = {
  name: string;       // Event type name
  payload: Schema;    // Payload schema for validation
};

Best Practices

Wrap primitive types in value objects to add validation and type safety: Email, UserId, Money, etc.
Each entity should represent a single aggregate root with its own lifecycle and business rules.
Don’t manipulate entity state directly. Use methods that enforce business rules and maintain invariants.
Emit domain events when something important happens in your domain. This enables event-driven architecture and decouples components.
Value objects should be immutable and compared by value. Use the branded type system for type safety.
Entities and value objects contain your core business logic. Write comprehensive tests for them.

Testing

Testing Value Objects

import { Email } from "@/domain/value-objects";

describe("Email", () => {
  it("creates valid email", async () => {
    const email = await Email.create("[email protected]");
    expect(email).toBe("[email protected]");
  });

  it("rejects invalid email", async () => {
    await expect(Email.create("invalid")).rejects.toThrow();
  });

  it("validates email format", async () => {
    expect(await Email.isValid("[email protected]")).toBe(true);
    expect(await Email.isValid("invalid")).toBe(false);
  });
});

Testing Entities

import { Account } from "@/domain/entities";

describe("Account", () => {
  it("allows deposits to active account", () => {
    const account = Account.create({
      id: "1",
      balance: 100,
      status: "active",
    });

    const updated = account.deposit(50);
    expect(updated.balance).toBe(150);
  });

  it("prevents withdrawal with insufficient funds", () => {
    const account = Account.create({
      id: "1",
      balance: 100,
      status: "active",
    });

    expect(() => account.withdraw(150)).toThrow("Insufficient funds");
  });

  it("prevents operations on frozen account", () => {
    const account = Account.create({
      id: "1",
      balance: 100,
      status: "frozen",
    });

    expect(() => account.deposit(50)).toThrow("Account is not active");
    expect(() => account.withdraw(50)).toThrow("Account is not active");
  });
});

Next Steps