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"
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
Use value objects for primitives
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.
Encapsulate business logic in methods
Don’t manipulate entity state directly. Use methods that enforce business rules and maintain invariants.
Use domain events for side effects
Emit domain events when something important happens in your domain. This enables event-driven architecture and decouples components.
Make value objects immutable
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