Entities are objects that have a distinct identity that runs through time and different representations. In TypeScript, an entity can be represented as a class with a unique identifier.
class User {
constructor(
private readonly id: string,
private name: string,
private email: string
) {}
getId(): string {
return this.id;
}
getName(): string {
return this.name;
}
setName(name: string): void {
this.name = name;
}
getEmail(): string {
return this.email;
}
setEmail(email: string): void {
this.email = email;
}
}
Value objects are objects that represent a descriptive aspect of the domain with no conceptual identity. They are immutable and are compared by their attributes.
class Address {
constructor(
private readonly street: string,
private readonly city: string,
private readonly zipCode: string
) {}
getStreet(): string {
return this.street;
}
getCity(): string {
return this.city;
}
getZipCode(): string {
return this.zipCode;
}
equals(other: Address): boolean {
return this.street === other.street &&
this.city === other.city &&
this.zipCode === other.zipCode;
}
}
Aggregates are clusters of related objects that are treated as a single unit. One entity in the aggregate acts as the root, and external objects can only interact with the aggregate through the root.
class Order {
constructor(
private readonly id: string,
private customer: User,
private orderItems: OrderItem[]
) {}
getId(): string {
return this.id;
}
getCustomer(): User {
return this.customer;
}
getOrderItems(): OrderItem[] {
return this.orderItems;
}
addOrderItem(item: OrderItem): void {
this.orderItems.push(item);
}
}
class OrderItem {
constructor(
private productName: string,
private quantity: number
) {}
getProductName(): string {
return this.productName;
}
getQuantity(): number {
return this.quantity;
}
}
Repositories are responsible for managing the persistence and retrieval of aggregates. They provide an abstraction layer between the domain model and the data storage.
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
class InMemoryOrderRepository implements OrderRepository {
private orders: Order[] = [];
async save(order: Order): Promise<void> {
this.orders.push(order);
}
async findById(id: string): Promise<Order | null> {
return this.orders.find(order => order.getId() === id) || null;
}
}
Domain services are used when a significant process or transformation in the domain involves multiple aggregates or entities and does not naturally belong to any single object.
class OrderService {
constructor(private orderRepository: OrderRepository) {}
async placeOrder(order: Order): Promise<void> {
// Some complex business logic here
await this.orderRepository.save(order);
}
}
To create an entity or a value object, you simply instantiate the corresponding class with the required parameters.
const user = new User('1', 'John Doe', '[email protected]');
const address = new Address('123 Main St', 'New York', '10001');
You can implement a repository by creating a class that implements the repository interface. In the example above, we implemented an in - memory order repository. In a real - world scenario, you would use a database instead of an in - memory array.
To use a domain service, you first create an instance of the service, passing in the required dependencies (such as a repository). Then you can call the methods of the service.
const orderRepository = new InMemoryOrderRepository();
const orderService = new OrderService(orderRepository);
const order = new Order('2', user, []);
orderService.placeOrder(order);
Bounded contexts are a key concept in DDD. A bounded context is a delimited boundary within which a particular model is defined and applicable. For example, in an e - commerce system, the shopping cart context and the payment context are different bounded contexts. Each bounded context has its own set of entities, value objects, and services.
Ubiquitous language is a common language shared by the developers and the domain experts. All the terms used in the code, such as class names, method names, and variable names, should be part of the ubiquitous language. This helps in better communication and understanding of the domain.
It is important to test each DDD component independently. You can use unit testing frameworks like Jest to write tests for entities, value objects, repositories, and domain services. For example, to test the OrderService
:
import { OrderService } from './orderService';
import { OrderRepository } from './orderRepository';
import { Order } from './order';
describe('OrderService', () => {
let orderRepository: OrderRepository;
let orderService: OrderService;
beforeEach(() => {
orderRepository = {
save: jest.fn(),
findById: jest.fn()
};
orderService = new OrderService(orderRepository);
});
it('should place an order', async () => {
const order = new Order('3', new User('1', 'John Doe', '[email protected]'), []);
await orderService.placeOrder(order);
expect(orderRepository.save).toHaveBeenCalledWith(order);
});
});
The domain model should be free from technical concerns such as database access, user interface, and network communication. It should only contain the business logic. This makes the domain model more maintainable and testable.
Domain Driven Design in TypeScript provides a powerful way to build complex software systems. By using concepts like entities, value objects, aggregates, repositories, and domain services, developers can create more maintainable, scalable, and understandable code. Following common practices like bounded contexts and ubiquitous language, and best practices like testing components and keeping the domain model pure, can further enhance the quality of the software.