@rineex/ddd
v1.5.1
Published
Domain Driven Design package for Rineex core modules
Downloads
992
Maintainers
Readme
@rineex/ddd
Domain-Driven Design (DDD) utilities and abstractions for building maintainable, scalable, and testable Node.js applications with clear separation of concerns.
Table of Contents
- Overview
- Philosophy
- Installation
- Quick Start
- Core Concepts
- API Reference
- Examples
- Best Practices
- Error Handling
- TypeScript Support
- Contributing
- License
Overview
@rineex/ddd is a lightweight TypeScript library that provides production-grade
abstractions for implementing Domain-Driven Design patterns. It enforces
architectural constraints that prevent common pitfalls in large-scale
applications while maintaining flexibility for domain-specific requirements.
Key Features
- Type-Safe Abstractions: Fully typed base classes for all DDD building blocks
- Immutability by Default: Value objects and entities are frozen to prevent accidental mutations
- Domain Events Support: First-class support for event sourcing and event-driven architectures
- Validation Framework: Built-in validation for value objects and entities
- Zero Dependencies: Only peer dependencies, minimal bundle footprint
- Production Ready: Used in high-performance systems at scale
- Comprehensive Error Types: Specific error classes for domain-driven validation failures
Philosophy
This library is built on core principles that enable teams to:
- Express Domain Logic Explicitly: Make business rules clear and testable
- Enforce Invariants: Validate state transitions at the boundary
- Manage Complexity: Use aggregates to create transaction boundaries
- Enable Event-Driven Architectures: Capture and publish domain events
- Maintain Testability: Pure domain logic with no hidden dependencies
Installation
npm install @rineex/ddd
# or
pnpm add @rineex/ddd
# or
yarn add @rineex/dddRequirements
- Node.js: 18.0 or higher
- TypeScript: 5.0 or higher (recommended: 5.9+)
- ES2020+: Target the module to ES2020 or higher for optimal compatibility
Quick Start
Here's a minimal example to get started:
import {
Entity,
AggregateRoot,
ValueObject,
DomainEvent,
ApplicationServicePort,
AggregateId,
} from '@rineex/ddd';
// Define a Value Object
class Email extends ValueObject<string> {
public static create(value: string) {
return new Email(value);
}
protected validate(props: string): void {
if (!props.includes('@')) {
throw new Error('Invalid email');
}
}
}
// Define an Aggregate Root
interface UserProps {
email: Email;
isActive: boolean;
}
class User extends AggregateRoot<UserProps> {
get email(): Email {
return this.props.email;
}
get isActive(): boolean {
return this.props.isActive;
}
protected validate(): void {
if (!this.email) {
throw new Error('Email is required');
}
}
}
// Create and use
const userId = AggregateId.create();
const user = new User({
id: userId,
createdAt: new Date(),
props: { email: Email.create('[email protected]'), isActive: true },
});
user.addEvent(
new UserCreatedEvent({
id: 'evt-1',
aggregateId: userId.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { email: user.email.value },
}),
);
const events = user.pullDomainEvents();
console.log(events); // [UserCreatedEvent]Core Concepts
Value Objects
Value Objects are immutable objects that are distinguished by their value rather than their identity. They represent concepts within the domain that have no lifecycle.
Characteristics
- Immutable: Cannot be changed after creation
- Identity by Value: Two value objects with the same properties are equal
- Self-Validating: Validation occurs during construction
- No Side Effects: Pure transformations only
Implementation
import { ValueObject } from '@rineex/ddd';
interface AddressProps {
street: string;
city: string;
postalCode: string;
country: string;
}
class Address extends ValueObject<AddressProps> {
get street(): string {
return this.props.street;
}
get city(): string {
return this.props.city;
}
get postalCode(): string {
return this.props.postalCode;
}
get country(): string {
return this.props.country;
}
public static create(props: AddressProps): Address {
return new Address(props);
}
protected validate(props: AddressProps): void {
if (!props.street || props.street.trim().length === 0) {
throw new Error('Street is required');
}
if (!props.city || props.city.trim().length === 0) {
throw new Error('City is required');
}
if (props.postalCode.length < 3) {
throw new Error('Invalid postal code');
}
}
}
// Usage
const address = Address.create({
street: '123 Main St',
city: 'New York',
postalCode: '10001',
country: 'USA',
});
// Immutability guaranteed
// address.props.street = 'foo'; // Error: Cannot assign to read only propertyType Safety with unwrapValueObject
When working with collections of value objects, use the unwrapValueObject
utility:
import { unwrapValueObject, UnwrapValueObject } from '@rineex/ddd';
interface UserProps {
tags: Tag[]; // where Tag extends ValueObject<string>
}
const unwrapped: UnwrapValueObject<UserProps> = unwrapValueObject(userProps);
// { tags: ['admin', 'moderator'] }Entities
Entities are objects with a unique identity that persists over time. Unlike value objects, they can be mutable and have a lifecycle.
Characteristics
- Unique Identity: Distinguished by a unique identifier (not just value)
- Lifecycle: Can be created, modified, and deleted
- Mutable: State can change, but identity remains constant
- Equality by Identity: Two entities with different properties but the same ID are equal
Implementation
import { Entity, AggregateId } from '@rineex/ddd';
import type { CreateEntityProps } from '@rineex/ddd';
interface OrderItemProps {
productId: string;
quantity: number;
unitPrice: number;
}
class OrderItem extends Entity<OrderItemProps> {
get productId(): string {
return this.props.productId;
}
get quantity(): number {
return this.props.quantity;
}
get unitPrice(): number {
return this.props.unitPrice;
}
get total(): number {
return this.quantity * this.unitPrice;
}
protected validate(): void {
if (this.quantity <= 0) {
throw new Error('Quantity must be greater than zero');
}
if (this.unitPrice < 0) {
throw new Error('Unit price cannot be negative');
}
}
}
// Creating an entity
const item = new OrderItem({
id: AggregateId.create(),
createdAt: new Date(),
props: {
productId: 'prod-123',
quantity: 2,
unitPrice: 29.99,
},
});
console.log(item.total); // 59.98Aggregate Roots
Aggregate Roots are entities that serve as entry points to aggregates. They enforce invariants, manage transactions, and raise domain events.
Characteristics
- Boundary: Define the scope of consistency within a transaction
- Invariant Enforcement: Validate rules that involve multiple entities or value objects
- Event Publisher: Raise domain events to notify other parts of the system
- Transaction Consistency: All changes within an aggregate should be persisted atomically
Implementation
import {
AggregateRoot,
AggregateId,
DomainEvent,
type DomainEventPayload,
} from '@rineex/ddd';
// Define domain events
class UserCreatedEvent extends DomainEvent {
public readonly eventName = 'UserCreated';
constructor(
props: Parameters<DomainEvent['constructor']>[0] & {
payload: { email: string };
},
) {
super(props);
}
}
class UserEmailChangedEvent extends DomainEvent {
public readonly eventName = 'UserEmailChanged';
}
// Define the aggregate
interface UserProps {
email: string;
isActive: boolean;
}
class User extends AggregateRoot<UserProps> {
get email(): string {
return this.props.email;
}
get isActive(): boolean {
return this.props.isActive;
}
public static create(email: string, id?: AggregateId): User {
const user = new User({
id: id || AggregateId.create(),
createdAt: new Date(),
props: { email, isActive: true },
});
user.addEvent(
new UserCreatedEvent({
id: crypto.randomUUID(),
aggregateId: user.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { email },
}),
);
return user;
}
public changeEmail(newEmail: string): void {
// Validate before changing
if (!newEmail.includes('@')) {
throw new Error('Invalid email format');
}
this.props = { ...this.props, email: newEmail };
this.addEvent(
new UserEmailChangedEvent({
id: crypto.randomUUID(),
aggregateId: this.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { oldEmail: this.props.email, newEmail },
}),
);
}
protected validate(): void {
if (!this.props.email || !this.props.email.includes('@')) {
throw new Error('User must have a valid email');
}
}
}
// Usage
const user = User.create('[email protected]');
user.changeEmail('[email protected]');
const events = user.pullDomainEvents(); // Remove events for publishing
console.log(events); // [UserCreatedEvent, UserEmailChangedEvent]Key Methods
addEvent(event: DomainEvent): void- Adds a domain event after validating invariantspullDomainEvents(): readonly DomainEvent[]- Retrieves and clears all domain eventsvalidate(): void- Abstract method for enforcing aggregate invariants
Domain Events
Domain Events represent significant things that happened in the domain. They are immutable records of past events and enable event-driven architectures.
Characteristics
- Immutable: Represent facts that have already occurred
- Self-Describing: Include all necessary information in the payload
- Serializable: Can be persisted and transmitted
- Versioned: Schema version allows for evolution
- Timestamped: Record when the event occurred
Implementation
import { DomainEvent, type DomainEventPayload } from '@rineex/ddd';
// Define event payloads (only primitives allowed)
interface OrderPlacedPayload extends DomainEventPayload {
customerId: string;
orderId: string;
totalAmount: number;
itemCount: number;
}
// Create event class
class OrderPlacedEvent extends DomainEvent<OrderPlacedPayload> {
public readonly eventName = 'OrderPlaced';
constructor(
props: Omit<
Parameters<DomainEvent<OrderPlacedPayload>['constructor']>[0],
'payload'
> & {
payload: OrderPlacedPayload;
},
) {
super(props);
}
}
// Using events
const event = new OrderPlacedEvent({
id: crypto.randomUUID(),
aggregateId: 'order-123',
schemaVersion: 1,
occurredAt: Date.now(),
payload: {
customerId: 'cust-456',
orderId: 'order-123',
totalAmount: 99.99,
itemCount: 3,
},
});
// Events are serializable
const primitives = event.toPrimitives();
// {
// id: '...',
// aggregateId: 'order-123',
// schemaVersion: 1,
// occurredAt: 1234567890,
// eventName: 'OrderPlaced',
// payload: { customerId: '...', orderId: '...', ... }
// }Application Services
Application Services orchestrate the business logic of the domain. They are the entry points for handling use cases and commands.
Characteristics
- Use Case Implementation: Each service handles a single, well-defined use case
- Port Interface: Implement a standard interface for consistency
- Orchestration: Coordinate domain objects, repositories, and external services
- Transaction Management: Define transaction boundaries
- Error Handling: Map domain errors to application-level responses
Implementation
import type { ApplicationServicePort } from '@rineex/ddd';
// Define input and output DTOs
interface CreateUserInput {
email: string;
name: string;
}
interface CreateUserOutput {
id: string;
email: string;
name: string;
createdAt: string;
}
// Implement the service
class CreateUserService implements ApplicationServicePort<
CreateUserInput,
CreateUserOutput
> {
constructor(
private readonly userRepository: UserRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(args: CreateUserInput): Promise<CreateUserOutput> {
// Check for existing user
const existing = await this.userRepository.findByEmail(args.email);
if (existing) {
throw new Error(`User with email ${args.email} already exists`);
}
// Create aggregate
const user = User.create(args.email, args.name);
// Persist
await this.userRepository.save(user);
// Publish events
const events = user.pullDomainEvents();
await this.eventPublisher.publishAll(events);
return {
id: user.id.uuid,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
};
}
}
// Using the service
const createUserService = new CreateUserService(userRepository, eventPublisher);
const result = await createUserService.execute({
email: '[email protected]',
name: 'John Doe',
});API Reference
Value Objects
ValueObject<T>
Abstract base class for all value objects.
export abstract class ValueObject<T> {
get value(): T;
public static is(vo: unknown): vo is ValueObject<unknown>;
public equals(other?: ValueObject<T>): boolean;
protected abstract validate(props: T): void;
}Methods:
value- Returns the immutable propertiesis(vo)- Type guard for runtime checkingequals(other)- Deep equality comparisonvalidate(props)- Validation logic (must be implemented)
Entities
Entity<EntityProps>
Abstract base class for domain entities.
export abstract class Entity<EntityProps> {
readonly id: AggregateId;
readonly createdAt: Date;
readonly metadata: { createdAt: string; id: string };
abstract validate(): void;
equals(entity: unknown): boolean;
}Constructor:
new Entity({
id?: AggregateId; // Generated if not provided
createdAt: Date; // Required
props: EntityProps; // Domain-specific properties
})Aggregate Roots
AggregateRoot<EntityProps>
Extends Entity with domain event support.
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
readonly domainEvents: readonly DomainEvent[];
abstract validate(): void;
addEvent(event: DomainEvent): void;
pullDomainEvents(): readonly DomainEvent[];
}Methods:
addEvent(event)- Add an event after validating invariantspullDomainEvents()- Get and clear all recorded eventsdomainEvents- Read-only view of current events
Domain Events
DomainEvent<T extends DomainEventPayload>
Abstract base class for domain events.
export abstract class DomainEvent<T extends DomainEventPayload> {
abstract readonly eventName: string;
readonly id: string;
readonly aggregateId: string;
readonly schemaVersion: number;
readonly occurredAt: number;
readonly payload: Readonly<T>;
toPrimitives(): {
id: string;
aggregateId: string;
schemaVersion: number;
occurredAt: number;
eventName: string;
payload: T;
};
}Application Services
ApplicationServicePort<I, O>
Interface for application services.
export interface ApplicationServicePort<I, O> {
execute: (args: I) => Promise<O>;
}Value Objects (Pre-built)
AggregateId
Represents the unique identifier for an aggregate.
class AggregateId extends ValueObject<{ uuid: string }> {
get uuid(): string;
public static create(id?: string): AggregateId;
}IPAddress
Validates IPv4 and IPv6 addresses.
class IPAddress extends ValueObject<string> {
public static create(value: string): IPAddress;
}URL
Validates web URLs.
class URL extends ValueObject<string> {
public static create(value: string): URL;
}UserAgent
Parses and validates user agent strings.
class UserAgent extends ValueObject<string> {
public static create(value: string): UserAgent;
}Error Types
DomainError
Base class for all domain errors.
export class DomainError extends Error {
constructor(message: string);
}EntityValidationError
Thrown when entity validation fails.
export class EntityValidationError extends DomainError {}InvalidValueObjectError
Thrown when value object validation fails.
export class InvalidValueObjectError extends DomainError {}ApplicationError
Thrown for application-level errors.
export class ApplicationError extends DomainError {}Examples
Complete Order Management System
Here's a realistic example showing how to structure a domain with multiple aggregates:
import {
AggregateRoot,
AggregateId,
ValueObject,
DomainEvent,
Entity,
ApplicationServicePort,
} from '@rineex/ddd';
// ============ Value Objects ============
interface MoneyProps {
amount: number;
currency: string;
}
class Money extends ValueObject<MoneyProps> {
get amount(): number {
return this.props.amount;
}
get currency(): string {
return this.props.currency;
}
public static create(amount: number, currency = 'USD'): Money {
return new Money({ amount, currency });
}
protected validate(props: MoneyProps): void {
if (props.amount < 0) {
throw new Error('Amount cannot be negative');
}
if (!props.currency || props.currency.length !== 3) {
throw new Error('Invalid currency code');
}
}
}
// ============ Entities ============
interface OrderLineProps {
productId: string;
quantity: number;
price: Money;
}
class OrderLine extends Entity<OrderLineProps> {
get productId(): string {
return this.props.productId;
}
get quantity(): number {
return this.props.quantity;
}
get price(): Money {
return this.props.price;
}
get subtotal(): Money {
return Money.create(this.price.amount * this.quantity, this.price.currency);
}
protected validate(): void {
if (this.quantity <= 0) {
throw new Error('Quantity must be positive');
}
}
}
// ============ Domain Events ============
class OrderCreatedEvent extends DomainEvent {
public readonly eventName = 'OrderCreated';
}
class OrderLineAddedEvent extends DomainEvent {
public readonly eventName = 'OrderLineAdded';
}
class OrderCompletedEvent extends DomainEvent {
public readonly eventName = 'OrderCompleted';
}
// ============ Aggregate Root ============
interface OrderProps {
customerId: string;
lines: OrderLine[];
status: 'pending' | 'completed' | 'cancelled';
total: Money;
}
class Order extends AggregateRoot<OrderProps> {
get customerId(): string {
return this.props.customerId;
}
get lines(): OrderLine[] {
return this.props.lines;
}
get status(): string {
return this.props.status;
}
get total(): Money {
return this.props.total;
}
public static create(customerId: string, id?: AggregateId): Order {
const order = new Order({
id: id || AggregateId.create(),
createdAt: new Date(),
props: {
customerId,
lines: [],
status: 'pending',
total: Money.create(0),
},
});
order.addEvent(
new OrderCreatedEvent({
id: crypto.randomUUID(),
aggregateId: order.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { customerId },
}),
);
return order;
}
public addLine(productId: string, quantity: number, price: Money): void {
const line = new OrderLine({
id: AggregateId.create(),
createdAt: new Date(),
props: { productId, quantity, price },
});
this.props.lines.push(line);
this.recalculateTotal();
this.addEvent(
new OrderLineAddedEvent({
id: crypto.randomUUID(),
aggregateId: this.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { productId, quantity },
}),
);
}
public complete(): void {
if (this.status !== 'pending') {
throw new Error('Only pending orders can be completed');
}
this.props.status = 'completed';
this.addEvent(
new OrderCompletedEvent({
id: crypto.randomUUID(),
aggregateId: this.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { total: this.total.amount },
}),
);
}
private recalculateTotal(): void {
const sum = this.lines.reduce((acc, line) => acc + line.subtotal.amount, 0);
this.props.total = Money.create(sum, 'USD');
}
protected validate(): void {
if (!this.customerId) {
throw new Error('Customer ID is required');
}
if (this.lines.length === 0) {
throw new Error('Order must have at least one line');
}
}
}
// ============ Application Service ============
interface CreateOrderInput {
customerId: string;
lines: { productId: string; quantity: number; price: number }[];
}
interface CreateOrderOutput {
id: string;
customerId: string;
total: number;
lineCount: number;
}
class CreateOrderService implements ApplicationServicePort<
CreateOrderInput,
CreateOrderOutput
> {
constructor(private readonly orderRepository: OrderRepository) {}
async execute(args: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create(args.customerId);
for (const line of args.lines) {
order.addLine(line.productId, line.quantity, Money.create(line.price));
}
order.complete();
await this.orderRepository.save(order);
return {
id: order.id.uuid,
customerId: order.customerId,
total: order.total.amount,
lineCount: order.lines.length,
};
}
}Best Practices
1. Make Invalid States Impossible
Use type system and validation to make invalid states impossible to construct:
// ❌ BAD: Can create invalid state
class User {
email: string;
isVerified: boolean;
}
// ✅ GOOD: Invalid state impossible
class UnverifiedUser extends ValueObject<{ email: string }> {}
class VerifiedUser extends ValueObject<{ email: string; verifiedAt: Date }> {}2. Keep Aggregates Small
Prefer small aggregates with clear boundaries over large aggregates with many entities:
// ❌ BAD: Too many entities in one aggregate
class Store extends AggregateRoot {
employees: Employee[];
inventory: InventoryItem[];
orders: Order[];
// ... many more
}
// ✅ GOOD: Separate aggregates with references
class Store extends AggregateRoot {
name: string;
// Reference to other aggregates by ID only
employeeIds: AggregateId[];
}
class Inventory extends AggregateRoot {
storeId: AggregateId;
items: InventoryItem[];
}3. Use Value Objects for Primitive Types
Wrap primitives that have domain meaning:
// ❌ BAD: Raw primitive types
interface User {
email: string;
phone: string;
age: number;
}
// ✅ GOOD: Domain-meaningful value objects
interface User {
email: Email;
phone: PhoneNumber;
age: Age;
}4. Validate at Boundaries
Perform all validation when creating aggregates, not repeatedly:
// ❌ BAD: Repeated validation
function updateEmail(email: string) {
if (!isValidEmail(email)) throw Error();
}
function sendEmail(email: string) {
if (!isValidEmail(email)) throw Error();
}
// ✅ GOOD: Single validation point
const email = Email.create(value); // Throws if invalid
updateEmail(email);
sendEmail(email);5. Event-Driven State Changes
All changes should be reflected in domain events:
// ✅ GOOD: Changes recorded as events
class User extends AggregateRoot {
changeEmail(newEmail: Email): void {
const oldEmail = this.email;
this.props.email = newEmail;
this.addEvent(
new EmailChangedEvent({
id: crypto.randomUUID(),
aggregateId: this.id.uuid,
schemaVersion: 1,
occurredAt: Date.now(),
payload: { oldEmail: oldEmail.value, newEmail: newEmail.value },
}),
);
}
}6. Publish Events After Persistence
Always publish events after persisting the aggregate:
async function handle(command: CreateUserCommand): Promise<void> {
// Create aggregate
const user = User.create(command.email);
// Persist first
await userRepository.save(user);
// Then publish
const events = user.pullDomainEvents();
await eventPublisher.publishAll(events);
}7. Immutability by Convention
Even though TypeScript doesn't enforce it, treat all domain objects as immutable:
// ✅ GOOD: Replace entire aggregate when state changes
class User extends AggregateRoot {
changeName(newName: string): void {
// Don't mutate: this.props.name = newName;
// Instead, create new object:
this.props = { ...this.props, name: newName };
}
}Error Handling
Handle different error scenarios appropriately:
import {
DomainError,
EntityValidationError,
InvalidValueObjectError,
ApplicationError,
} from '@rineex/ddd';
try {
const email = Email.create('invalid-email');
} catch (error) {
if (error instanceof InvalidValueObjectError) {
// Handle value object validation errors
console.error('Invalid email format:', error.message);
}
}
try {
const user = new User({
id: AggregateId.create(),
createdAt: new Date(),
props: { email: Email.create('[email protected]') },
});
user.validate();
} catch (error) {
if (error instanceof EntityValidationError) {
// Handle entity validation errors
console.error('User invariant violated:', error.message);
}
}
try {
await userService.execute(input);
} catch (error) {
if (error instanceof ApplicationError) {
// Handle application-level errors
console.error('Service failed:', error.message);
} else if (error instanceof DomainError) {
// Catch-all for domain errors
console.error('Domain error:', error.message);
}
}TypeScript Support
This library is built with TypeScript 5.9+ and provides comprehensive type safety:
// Full type inference
const user = User.create('[email protected]');
const id: AggregateId = user.id; // Correctly typed
// Type-safe event handling
const events = user.pullDomainEvents();
events.forEach(event => {
if (event instanceof UserCreatedEvent) {
// Type guard works correctly
const payload = event.payload; // Correctly inferred type
}
});
// Proper generic constraints
class MyAggregate extends AggregateRoot<MyProps> {
// Full type safety with MyProps
}Recommended TypeScript Configuration
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}Contributing
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Write tests for new functionality
- Ensure all tests pass (
pnpm test) - Follow the code style (
pnpm lint) - Commit with clear messages
- Push to the branch and create a Pull Request
Development Setup
# Install dependencies
pnpm install
# Run tests
pnpm test
# Run linter
pnpm lint
# Check types
pnpm check-types
# Build the package
pnpm buildCode Style
- Follow the existing code style
- Use TypeScript strict mode
- Write descriptive variable and function names
- Add JSDoc comments for public APIs
- Keep functions small and focused
License
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Related Resources
- Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Architecture Patterns with Python by Harry J. W. Percival and Bob Gregory
- TypeScript Handbook
Support
For issues, questions, or suggestions, please open an issue on GitHub.
Made with ❤️ by the Rineex Team
