Domain Modeling
Domain modeling is the heart of Domain-Driven Design. Stratix provides powerful primitives to model your business domain accurately.
Core Building Blocks
Entity
An Entity is an object with a unique identity that persists over time.
Characteristics
- ✅ Has a unique identifier
- ✅ Mutable (can change over time)
- ✅ Identity-based equality
- ✅ Has lifecycle (created, updated, deleted)
Example
import { Entity, EntityId } from '@stratix/core';
type UserId = EntityId<'User'>;
export class User extends Entity<'User'> {
constructor(
id: UserId,
private email: string,
private name: string,
private isActive: boolean,
createdAt: Date,
updatedAt: Date
) {
super(id, createdAt, updatedAt);
}
// Getters
get Email(): string {
return this.email;
}
get Name(): string {
return this.name;
}
get IsActive(): boolean {
return this.isActive;
}
// Business methods
changeName(newName: string): void {
if (!newName || newName.trim().length === 0) {
throw new Error('Name cannot be empty');
}
this.name = newName;
this.touch(); // Updates updatedAt timestamp
}
deactivate(): void {
if (!this.isActive) {
throw new Error('User is already inactive');
}
this.isActive = false;
this.touch();
}
activate(): void {
if (this.isActive) {
throw new Error('User is already active');
}
this.isActive = true;
this.touch();
}
}
// Usage
const userId = EntityId.create<'User'>();
const user = new User(
userId,
'john@example.com',
'John Doe',
true,
new Date(),
new Date()
);
user.changeName('John Smith');
console.log(user.Name); // "John Smith"
Entity Equality
Entities are equal if they have the same ID:
const user1 = new User(userId, 'john@example.com', 'John', true, now, now);
const user2 = new User(userId, 'jane@example.com', 'Jane', false, now, now);
console.log(user1.equals(user2)); // true (same ID)
Aggregate Root
An Aggregate Root is an entity that serves as the entry point to an aggregate - a cluster of related objects treated as a single unit.
Characteristics
- ✅ All characteristics of Entity
- ✅ Can emit domain events
- ✅ Enforces invariants across the aggregate
- ✅ Controls access to child entities
Example
import { AggregateRoot, EntityId } from '@stratix/core';
type OrderId = EntityId<'Order'>;
export class Order extends AggregateRoot<'Order'> {
private items: OrderItem[] = [];
private status: OrderStatus = OrderStatus.PENDING;
constructor(
id: OrderId,
private customerId: string,
createdAt: Date,
updatedAt: Date
) {
super(id, createdAt, updatedAt);
}
// Add item to order
addItem(productId: string, quantity: number, price: Money): void {
// Enforce invariant: can only add items to pending orders
if (this.status !== OrderStatus.PENDING) {
throw new Error('Cannot add items to non-pending order');
}
const item = new OrderItem(productId, quantity, price);
this.items.push(item);
// Emit domain event
this.addDomainEvent(new OrderItemAddedEvent(this.id, productId, quantity));
this.touch();
}
// Remove item from order
removeItem(productId: string): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Cannot remove items from non-pending order');
}
const index = this.items.findIndex(item => item.productId === productId);
if (index === -1) {
throw new Error('Item not found in order');
}
this.items.splice(index, 1);
this.addDomainEvent(new OrderItemRemovedEvent(this.id, productId));
this.touch();
}
// Confirm order
confirm(): void {
// Enforce invariant: order must have items
if (this.items.length === 0) {
throw new Error('Cannot confirm empty order');
}
// Enforce invariant: order must be pending
if (this.status !== OrderStatus.PENDING) {
throw new Error('Only pending orders can be confirmed');
}
this.status = OrderStatus.CONFIRMED;
this.addDomainEvent(new OrderConfirmedEvent(this.id, this.calculateTotal()));
this.touch();
}
// Calculate total
calculateTotal(): Money {
return this.items.reduce(
(total, item) => total.add(item.price.multiply(item.quantity)),
Money.USD(0)
);
}
// Getters
get Items(): readonly OrderItem[] {
return this.items; // Return readonly to prevent external modification
}
get Status(): OrderStatus {
return this.status;
}
get CustomerId(): string {
return this.customerId;
}
}
// Child entity (not an aggregate root)
class OrderItem {
constructor(
public readonly productId: string,
public readonly quantity: number,
public readonly price: Money
) {}
}
enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED'
}
Domain Events
// Retrieve and clear domain events
const events = order.getDomainEvents();
order.clearDomainEvents();
// Publish events
for (const event of events) {
await eventBus.publish(event);
}
Value Object
A Value Object is an immutable object defined by its attributes, not its identity.
Characteristics
- ✅ Immutable (cannot change after creation)
- ✅ Value-based equality
- ✅ No identity
- ✅ Self-validating
Example
import { ValueObject, Result, Success, Failure } from '@stratix/core';
interface EmailProps {
value: string;
}
export class Email extends ValueObject<EmailProps> {
private constructor(props: EmailProps) {
super(props);
}
static create(email: string): Result<Email> {
// Validation
if (!email || email.trim().length === 0) {
return Failure.create(new Error('Email cannot be empty'));
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return Failure.create(new Error('Invalid email format'));
}
return Success.create(new Email({ value: email.toLowerCase() }));
}
get value(): string {
return this.props.value;
}
get domain(): string {
return this.props.value.split('@')[1];
}
}
// Usage
const emailResult = Email.create('john@example.com');
if (emailResult.isSuccess) {
const email = emailResult.value;
console.log(email.value); // "john@example.com"
console.log(email.domain); // "example.com"
}
Built-in Value Objects
Stratix provides 11 built-in value objects:
import {
Money,
Currency,
Email,
URL,
PhoneNumber,
UUID,
DateRange,
Percentage,
Address
} from '@stratix/core';
// Money
const price = Money.USD(99.99);
const discounted = price.multiply(0.8);
console.log(discounted.format()); // "$79.99"
// Email
const email = Email.create('user@example.com');
// Phone Number
const phoneResult = PhoneNumber.create('+14155552671');
if (phoneResult.isSuccess) {
console.log(phoneResult.value.format()); // "+1 (415) 555-2671"
}
// Date Range
const rangeResult = DateRange.create(
new Date('2024-01-01'),
new Date('2024-12-31')
);
// Percentage
const discount = Percentage.fromPercentage(15); // 15%
// Address
const addressResult = Address.create({
street: '123 Main St',
city: 'San Francisco',
state: 'CA',
postalCode: '94102',
country: 'US'
});
Value Object Equality
Value objects are equal if their values are equal:
const email1 = Email.create('john@example.com').value;
const email2 = Email.create('john@example.com').value;
console.log(email1.equals(email2)); // true (same value)
Domain Service
A Domain Service contains business logic that doesn't naturally fit within an entity or value object.
When to Use
Use a Domain Service when:
- ✅ Logic involves multiple entities
- ✅ Logic doesn't belong to any single entity
- ✅ Operation is stateless
Example
import { DomainService } from '@stratix/core';
export class PricingService extends DomainService {
calculateOrderDiscount(order: Order, customer: Customer): Money {
let discount = Money.USD(0);
// VIP customers get 10% discount
if (customer.isVIP) {
const total = order.calculateTotal();
discount = total.multiply(0.1);
}
// Orders over $100 get additional $10 off
if (order.calculateTotal().amount > 100) {
discount = discount.add(Money.USD(10));
}
return discount;
}
calculateShippingCost(order: Order, address: Address): Money {
const total = order.calculateTotal();
// Free shipping over $50
if (total.amount >= 50) {
return Money.USD(0);
}
// International shipping
if (address.country !== 'US') {
return Money.USD(25);
}
// Domestic shipping
return Money.USD(5);
}
}
// Usage
const pricingService = new PricingService();
const discount = pricingService.calculateOrderDiscount(order, customer);
const shipping = pricingService.calculateShippingCost(order, address);
Repository Pattern
Repositories provide an abstraction for data access:
// Domain layer - Interface
export interface IProductRepository {
save(product: Product): Promise<void>;
findById(id: EntityId<'Product'>): Promise<Product | null>;
findAll(): Promise<Product[]>;
delete(id: EntityId<'Product'>): Promise<void>;
}
// Infrastructure layer - Implementation
export class PostgresProductRepository implements IProductRepository {
constructor(private db: Database) {}
async save(product: Product): Promise<void> {
await this.db('products')
.insert(this.toPersistence(product))
.onConflict('id')
.merge();
}
async findById(id: EntityId<'Product'>): Promise<Product | null> {
const row = await this.db('products').where({ id }).first();
return row ? this.toDomain(row) : null;
}
private toDomain(row: any): Product {
return new Product(
row.id,
row.name,
Money.USD(row.price),
row.created_at,
row.updated_at
);
}
private toPersistence(product: Product): any {
return {
id: product.id,
name: product.name,
price: product.price.amount,
created_at: product.createdAt,
updated_at: product.updatedAt
};
}
}
Best Practices
1. Keep Entities Focused
// ✅ Good: Focused entity
export class Product {
updatePrice(newPrice: Money): void {
this.price = newPrice;
}
}
// ❌ Bad: Entity doing too much
export class Product {
async saveToDatabase(): Promise<void> { /* ... */ }
async sendEmailNotification(): Promise<void> { /* ... */ }
}
2. Make Value Objects Immutable
// ✅ Good: Immutable
export class Money {
add(other: Money): Money {
return new Money(this.amount + other.amount, this.currency);
}
}
// ❌ Bad: Mutable
export class Money {
add(other: Money): void {
this.amount += other.amount; // Mutates!
}
}
3. Validate in Constructors
// ✅ Good: Validation in factory method
export class Email {
static create(value: string): Result<Email> {
if (!this.isValid(value)) {
return Failure.create(new Error('Invalid email'));
}
return Success.create(new Email({ value }));
}
}
// ❌ Bad: No validation
export class Email {
constructor(public value: string) {}
}
4. Use Aggregate Roots for Consistency
// ✅ Good: Modify through aggregate root
order.addItem(productId, quantity, price);
// ❌ Bad: Direct modification of child entity
order.items.push(new OrderItem(/* ... */));
Next Steps
- Result Pattern - Type-safe error handling
- CQRS - Commands and queries
- Architecture Overview - Hexagonal architecture