Domain Driven Design with TypeScript

Domain Driven Design (DDD) is a software development approach that focuses on modeling software to match the real - world business domain. It helps in creating more maintainable, scalable, and understandable software systems by separating the business logic from the technical details. TypeScript, a statically typed superset of JavaScript, provides excellent support for implementing DDD concepts. It allows developers to write more robust code with type checking, interfaces, and classes, which are essential for DDD. In this blog post, we will explore the fundamental concepts of DDD in the context of TypeScript, learn about usage methods, common practices, and best practices.

Table of Contents

  1. Fundamental Concepts of Domain Driven Design in TypeScript
    • Entities
    • Value Objects
    • Aggregates
    • Repositories
    • Domain Services
  2. Usage Methods
    • Creating Entities and Value Objects
    • Implementing Repositories
    • Using Domain Services
  3. Common Practices
    • Bounded Contexts
    • Ubiquitous Language
  4. Best Practices
    • Testing DDD Components
    • Keeping the Domain Model Pure
  5. Conclusion
  6. References

Fundamental Concepts of Domain Driven Design in TypeScript

Entities

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

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

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

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

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);
    }
}

Usage Methods

Creating Entities and Value Objects

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');

Implementing Repositories

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.

Using Domain Services

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);

Common Practices

Bounded Contexts

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

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.

Best Practices

Testing DDD Components

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);
    });
});

Keeping the Domain Model Pure

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.

Conclusion

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.

References