Building Robust Domain Models in TypeScript: A Step-by-Step Tutorial

Learn how to build clean and robust domain models in TypeScript with practical steps, examples, and best practices for beginners.

Creating a robust domain model is an important step when developing maintainable and clear software applications. Domain models represent real-world concepts and business logic in a way that's easy to understand and extend. In this TypeScript tutorial, we'll learn how to build strong domain models from scratch using classes, interfaces, and basic design principles. Let's get started!

### What is a Domain Model? A domain model is an abstract representation of the main concepts and entities in your problem domain. For example, if you’re building an e-commerce app, domain models might represent Products, Orders, and Customers. These models encapsulate both data and behaviors related to these entities.

### Step 1: Define the Core Entity Interface First, define the shape of your core entity using a TypeScript interface. This helps describe the data properties consistently.

typescript
interface ProductProps {
  id: string;
  name: string;
  price: number;
  availableStock: number;
}

### Step 2: Create the Domain Model Class Use a class to build the domain model. It holds the data (properties) and behaviors (methods). Make properties private or readonly when needed for encapsulation and data integrity.

typescript
class Product {
  private availableStock: number;

  constructor(
    public readonly id: string,
    public name: string,
    public price: number,
    availableStock: number
  ) {
    this.availableStock = availableStock;
  }

  // Business logic: check if the product is in stock
  isInStock(): boolean {
    return this.availableStock > 0;
  }

  // Business logic: reduce stock when product is sold
  reduceStock(quantity: number): void {
    if(quantity <= 0) {
      throw new Error('Quantity must be positive');
    }

    if(this.availableStock < quantity) {
      throw new Error('Insufficient stock');
    }

    this.availableStock -= quantity;
  }

  // Method to get current stock
  getStock(): number {
    return this.availableStock;
  }
}

### Step 3: Use the Domain Model Now let’s instantiate the Product class and interact with it using its methods.

typescript
const product = new Product('p1', 'Laptop', 1500, 10);

console.log(product.isInStock());  // true
console.log(product.getStock());   // 10

product.reduceStock(3);
console.log(product.getStock());   // 7

// product.reduceStock(20);  // Throws error: Insufficient stock

### Step 4: Extend with Validation and Immutability Robust domain models often protect their internal state from invalid changes. You can add validation in the constructor and use `readonly` properties where suitable.

typescript
class SafeProduct {
  private availableStock: number;

  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly price: number,
    availableStock: number
  ) {
    if(price <= 0) {
      throw new Error('Price must be greater than zero');
    }

    if(availableStock < 0) {
      throw new Error('Stock cannot be negative');
    }

    this.availableStock = availableStock;
  }

  isInStock(): boolean {
    return this.availableStock > 0;
  }

  reduceStock(quantity: number): void {
    if(quantity <= 0) {
      throw new Error('Quantity must be positive');
    }

    if(this.availableStock < quantity) {
      throw new Error('Insufficient stock');
    }

    this.availableStock -= quantity;
  }

  getStock(): number {
    return this.availableStock;
  }
}

### Summary By following these simple steps, you can build domain models in TypeScript that are clean, maintainable, and resilient. Start by defining clear interfaces, encapsulate data with classes, add business logic inside methods, and protect internal state with validation and readonly properties. This foundation will help your application code stay organized and flexible.