effective-domain-modeling
Effective Domain Modeling in TypeScript:
Let's go through each key point with a corresponding TypeScript code example.
Understand the Domain:
Before creating models in TypeScript, it's essential to thoroughly understand the business or problem domain. This understanding helps ensure that the models and types you create are a true reflection of the real-world entities and rules they represent.
No code example is provided here as this point is about understanding rather than coding.
Leverage TypeScript's Type System:
TypeScript's static type system allows you to define structures that mirror real-world entities. You can enforce business rules at the type level, reducing the possibility of invalid data entering your system.
Example:
type OrderStatus = 'pending' | 'completed' | 'cancelled';
interface Order {
orderId: string;
status: OrderStatus;
amount: number;
}
Use Classes for Domain Entities:
Classes in TypeScript are used to represent entities with both data and behavior. This aligns with domain-driven design by combining data attributes with methods that represent business logic.
Example:
class Customer {
name: string;
private orders: Order[] = [];
constructor(name: string) {
this.name = name;
}
addOrder(order: Order) {
this.orders.push(order);
}
}
Prefer Composition Over Inheritance:
Composition involves creating complex types by combining simpler ones. This is usually more flexible than inheritance, as it allows you to mix and match different aspects of entities without being constrained by a single inheritance chain.
Example:
class Address {
street: string;
city: string;
constructor(street: string, city: string) {
this.street = street;
this.city = city;
}
}
class Person {
name: string;
address: Address;
constructor(name: string, address: Address) {
this.name = name;
this.address = address;
}
}
Define Strict Interfaces:
Interfaces in TypeScript define contracts within your code. By representing roles or capabilities as interfaces, you ensure that any entity playing a role must adhere to the contract defined by the interface.
Example:
interface Identifiable {
id: string;
}
interface Payable {
makePayment(amount: number): void;
}
class Employee implements Identifiable, Payable {
id: string;
name: string;
constructor(id: string, name: string) {
this.id = id;
this.name = name;
}
makePayment(amount: number) {
// Payment logic
}
}
Each of these examples demonstrates a fundamental concept in domain-driven design as it applies to TypeScript. The types and interfaces serve to enforce the business rules and relationships that govern the domain being modeled.
Here's how you can demonstrate these key points through TypeScript code examples:
Implement Validation:
Include methods within your domain classes that validate the data before it is used or changed to maintain data integrity.
Example:
class Product {
id: string;
price: number;
constructor(id: string, price: number) {
if (price <= 0) {
throw new Error('Price must be greater than zero.');
}
this.id = id;
this.price = price;
}
}
Value Objects for Immutability:
Design value objects as immutable by making their properties readonly and ensuring that any changes result in a new object.
Example:
class Money {
readonly value: number;
readonly currency: string;
constructor(value: number, currency: string) {
this.value = value;
this.currency = currency;
}
}
Aggregate Roots:
Designate certain entities as aggregate roots which act as the gatekeepers to a cluster of related objects.
Example:
class OrderAggregateRoot {
private order: Order;
private orderLines: OrderLine[];
constructor(order: Order) {
this.order = order;
this.orderLines = [];
}
addOrderLine(orderLine: OrderLine) {
this.orderLines.push(orderLine);
}
}
Encapsulate Business Logic:
Keep business logic within the domain model classes to ensure a single source of truth and maintainability.
Example:
class Account {
private balance: number;
constructor(initialBalance: number) {
this.balance = initialBalance;
}
deposit(amount: number): void {
// Business logic for deposit
this.balance += amount;
}
withdraw(amount: number): void {
// Business logic for withdrawal
if (amount > this.balance) {
throw new Error('Insufficient balance.');
}
this.balance -= amount;
}
}
Utilize TypeScript's Enumerations:
- Use enums to represent a set of named constants, which can clarify intent and prevent invalid values.
- Example:
enum OrderStatus {
Pending,
Shipped,
Delivered,
Cancelled
}
- Example:
Domain Events:
Create classes or interfaces to represent events that can occur within your domain, which can then be published and subscribed to.
Example:
interface DomainEvent {
timestamp: Date;
// ...
}
class OrderShippedEvent implements DomainEvent {
timestamp = new Date();
constructor(public orderId: string) {}
}
Repository Interfaces:
- Define interfaces for repositories to abstract away the data persistence mechanisms.
- Example:
interface IRepository<T> {
findById(id: string): T | null;
save(entity: T): void;
// ...
}
- Example:
Service Layer:
- Use a service layer to encapsulate business logic that doesn't belong to a domain entity.
- Example:
class PaymentService {
processPayment(accountId: string, amount: number): void {
// Logic to process payment
}
}
- Example:
Model Invariants:
Enforce invariants through type definitions and class methods to ensure that your domain objects are always in a valid state.
Example:
class Customer {
private name: string;
private email: string;
constructor(name: string, email: string) {
if (!email.includes('@')) {
throw new Error('Email must be valid.');
}
this.name = name;
this.email = email;
}
}
Use Discriminated Unions for Domain States:
Model entities that can be in various states using discriminated unions, which are a combination of types with a common, single-field discriminator.
Example:
type Payment =
| { state: 'pending'; transactionId: string }
| { state: 'completed'; receipt: string }
| { state: 'failed'; error: string };
function processPaymentStatus(payment: Payment) {
switch (payment.state) {
case 'pending':
// handle pending
break;
case 'completed':
// handle completed
break;
case 'failed':
// handle failed
break;
}
}
These examples demonstrate how to apply domain-driven design principles in a TypeScript context to ensure the domain model is robust, flexible, and correctly encapsulates the business logic.