DEV Community

Cover image for Domain-Driven Design for Web Applications: Practical Implementation Guide
Aarav Joshi
Aarav Joshi

Posted on

1

Domain-Driven Design for Web Applications: Practical Implementation Guide

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

In the realm of web application development, architecture is everything. I've spent years implementing various architectural patterns, and Domain-Driven Design (DDD) consistently stands out as a powerful approach for tackling complex business problems. Let me share what I've learned about applying DDD principles to web applications.

Domain-Driven Design focuses on the core domain and domain logic, placing the primary project emphasis on the business domain and domain logic. Through my experience working with numerous teams, I've found that DDD provides a structured approach that connects technical implementation to an evolving business model.

The heart of DDD lies in creating a model that reflects business processes rather than technical concerns. When I first started implementing DDD in a large e-commerce platform, the transformation was remarkable—suddenly business stakeholders and developers were speaking the same language.

Ubiquitous Language

A ubiquitous language creates a common vocabulary between developers and domain experts. This shared language appears in code, documentation, and conversation, eliminating the costly translation between technical and business terminology.

I recall working on a financial application where confusion between "transfer" and "transaction" caused significant rework. Once we established precise definitions in our ubiquitous language, these misunderstandings vanished. The language must be rigorous and consistent:

// Before ubiquitous language implementation
function moveMoneyBetweenAccounts(fromAcc, toAcc, amount) {
  // Implementation
}

// After ubiquitous language implementation
function transferFunds(sourceAccount, destinationAccount, transferAmount) {
  // Implementation that precisely matches business terminology
}
Enter fullscreen mode Exit fullscreen mode

Bounded Contexts

Bounded contexts divide a complex domain into manageable subdomains. Each context has its own ubiquitous language and model, with explicit boundaries between them.

In my experience implementing a healthcare system, patient information meant different things to billing, treatment, and research departments. By establishing separate bounded contexts, we allowed each department to define "patient" according to their specific needs.

// Billing Bounded Context
namespace Billing {
  class Patient {
    constructor(
      public id: string,
      public insuranceDetails: InsuranceInfo,
      public billingAddress: Address
    ) {}
  }
}

// Treatment Bounded Context
namespace Treatment {
  class Patient {
    constructor(
      public id: string,
      public medicalHistory: MedicalRecord[],
      public currentTreatments: Treatment[]
    ) {}
  }
}
Enter fullscreen mode Exit fullscreen mode

A context map documents the relationships between bounded contexts, which proved invaluable when onboarding new team members to our project.

Entities

Entities are domain objects defined by their identity rather than their attributes. They have lifecycles, can change over time, and are tracked by their identity.

When building a customer management system, I implemented User entities with distinct identities, enabling tracking across multiple interactions:

class User {
  constructor(id, email, name) {
    this.id = id;
    this.email = email;
    this.name = name;
    this.createdAt = new Date();
    this.updatedAt = new Date();
  }

  changeName(newName) {
    this.name = newName;
    this.updatedAt = new Date();
    return this;
  }

  changeEmail(newEmail) {
    if (!this.isValidEmail(newEmail)) {
      throw new Error('Invalid email format');
    }
    this.email = newEmail;
    this.updatedAt = new Date();
    return this;
  }

  isValidEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  equals(other) {
    if (!(other instanceof User)) return false;
    return this.id === other.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how the entity maintains its identity even when attributes change, and how it enforces its own business rules.

Value Objects

Value objects describe, quantify, or measure aspects of the domain. They are immutable, have no identity, and are defined by their attributes.

In a mapping application I developed, we used value objects for coordinates:

class Coordinates {
  constructor(latitude, longitude) {
    if (!this.isValidLatitude(latitude) || !this.isValidLongitude(longitude)) {
      throw new Error('Invalid coordinates');
    }

    this._latitude = latitude;
    this._longitude = longitude;

    // Make this object immutable
    Object.freeze(this);
  }

  get latitude() { return this._latitude; }
  get longitude() { return this._longitude; }

  isValidLatitude(lat) {
    return lat >= -90 && lat <= 90;
  }

  isValidLongitude(lng) {
    return lng >= -180 && lng <= 180;
  }

  equals(other) {
    if (!(other instanceof Coordinates)) return false;
    return this.latitude === other.latitude && 
           this.longitude === other.longitude;
  }

  distanceTo(other) {
    // Calculate distance between coordinates
    // Implementation of haversine formula
    const R = 6371; // Earth's radius in km
    const dLat = this.toRadians(other.latitude - this.latitude);
    const dLon = this.toRadians(other.longitude - this.longitude);
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
              Math.cos(this.toRadians(this.latitude)) * Math.cos(this.toRadians(other.latitude)) *
              Math.sin(dLon/2) * Math.sin(dLon/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
  }

  toRadians(degrees) {
    return degrees * (Math.PI/180);
  }
}
Enter fullscreen mode Exit fullscreen mode

Value objects can be composed to build complex structures while maintaining immutability.

Aggregates

Aggregates group related entities and value objects into a single unit with a clear boundary. The aggregate root is the entry point for any external interaction with the objects inside.

In an order management system I designed, the Order aggregate contained OrderItems and maintained integrity constraints:

class Order {
  constructor(orderId, customerId) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.items = [];
    this.status = 'DRAFT';
    this.createdAt = new Date();
    this.modifiedAt = new Date();
  }

  addItem(product, quantity, unitPrice) {
    if (this.status !== 'DRAFT') {
      throw new Error('Cannot modify a finalized order');
    }

    // Check if item already exists
    const existingItem = this.items.find(item => item.productId === product.id);
    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      const newItem = new OrderItem(this.orderId, product.id, quantity, unitPrice);
      this.items.push(newItem);
    }

    this.modifiedAt = new Date();
    return this;
  }

  removeItem(productId) {
    if (this.status !== 'DRAFT') {
      throw new Error('Cannot modify a finalized order');
    }

    const initialCount = this.items.length;
    this.items = this.items.filter(item => item.productId !== productId);

    if (this.items.length === initialCount) {
      throw new Error('Product not found in order');
    }

    this.modifiedAt = new Date();
    return this;
  }

  finalizeOrder() {
    if (this.items.length === 0) {
      throw new Error('Cannot finalize an empty order');
    }

    this.status = 'FINALIZED';
    this.modifiedAt = new Date();
    return this;
  }

  calculateTotal() {
    return this.items.reduce((total, item) => {
      return total + item.calculateSubtotal();
    }, 0);
  }
}

class OrderItem {
  constructor(orderId, productId, quantity, unitPrice) {
    this.orderId = orderId;
    this.productId = productId;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
  }

  increaseQuantity(amount) {
    this.quantity += amount;
    return this;
  }

  calculateSubtotal() {
    return this.quantity * this.unitPrice;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Order aggregate ensures that OrderItems are never accessed directly from outside the aggregate boundary, maintaining consistency.

Domain Events

Domain events represent significant occurrences within the domain. They communicate changes between different parts of a system while maintaining loose coupling.

In a collaborative document editing system, I implemented events to notify about document changes:

class DocumentEditedEvent {
  constructor(documentId, userId, timestamp) {
    this.documentId = documentId;
    this.userId = userId;
    this.timestamp = timestamp || new Date();
  }
}

class DocumentEventPublisher {
  constructor() {
    this.subscribers = [];
  }

  subscribe(subscriber) {
    this.subscribers.push(subscriber);
  }

  publish(event) {
    this.subscribers.forEach(subscriber => {
      setTimeout(() => {
        subscriber.handleEvent(event);
      }, 0);
    });
  }
}

class Document {
  constructor(id, content, version, publisher) {
    this.id = id;
    this.content = content;
    this.version = version;
    this.publisher = publisher;
  }

  edit(userId, newContent) {
    this.content = newContent;
    this.version += 1;

    // Publish domain event
    this.publisher.publish(new DocumentEditedEvent(this.id, userId));

    return this;
  }
}

// Usage
const publisher = new DocumentEventPublisher();
const document = new Document('doc-123', 'Initial content', 1, publisher);

// Subscribe to events
publisher.subscribe({
  handleEvent: (event) => {
    if (event instanceof DocumentEditedEvent) {
      console.log(`Document ${event.documentId} was edited by user ${event.userId} at ${event.timestamp}`);
      // Update UI or sync with server
    }
  }
});

// Edit document
document.edit('user-456', 'Updated content');
Enter fullscreen mode Exit fullscreen mode

Domain events enable a reactive system where different components can respond to changes without tight coupling.

Repositories

Repositories provide a collection-like interface for accessing domain objects, abstracting away the underlying persistence mechanism.

In a project management application, I implemented a task repository:

interface TaskRepository {
  findById(id: string): Promise<Task | null>;
  findByAssignee(userId: string): Promise<Task[]>;
  findOverdueTasks(): Promise<Task[]>;
  save(task: Task): Promise<void>;
  remove(taskId: string): Promise<boolean>;
}

class PostgresTaskRepository implements TaskRepository {
  constructor(private db: Database) {}

  async findById(id: string): Promise<Task | null> {
    const result = await this.db.query(
      'SELECT * FROM tasks WHERE id = $1',
      [id]
    );

    if (result.rows.length === 0) {
      return null;
    }

    return this.mapRowToTask(result.rows[0]);
  }

  async findByAssignee(userId: string): Promise<Task[]> {
    const result = await this.db.query(
      'SELECT * FROM tasks WHERE assignee_id = $1',
      [userId]
    );

    return result.rows.map(this.mapRowToTask);
  }

  async findOverdueTasks(): Promise<Task[]> {
    const today = new Date();
    const result = await this.db.query(
      'SELECT * FROM tasks WHERE due_date < $1 AND status != $2',
      [today, 'COMPLETED']
    );

    return result.rows.map(this.mapRowToTask);
  }

  async save(task: Task): Promise<void> {
    // For existing tasks (update)
    if (task.id) {
      await this.db.query(
        `UPDATE tasks 
         SET title = $1, description = $2, status = $3, assignee_id = $4, due_date = $5 
         WHERE id = $6`,
        [task.title, task.description, task.status, task.assigneeId, task.dueDate, task.id]
      );
      return;
    }

    // For new tasks (insert)
    const result = await this.db.query(
      `INSERT INTO tasks (title, description, status, assignee_id, due_date) 
       VALUES ($1, $2, $3, $4, $5) RETURNING id`,
      [task.title, task.description, task.status, task.assigneeId, task.dueDate]
    );

    task.id = result.rows[0].id;
  }

  async remove(taskId: string): Promise<boolean> {
    const result = await this.db.query(
      'DELETE FROM tasks WHERE id = $1',
      [taskId]
    );

    return result.rowCount > 0;
  }

  private mapRowToTask(row: any): Task {
    return new Task(
      row.id,
      row.title,
      row.description,
      row.status,
      row.assignee_id,
      row.due_date
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The repository pattern separates domain logic from data access concerns, making the system more testable and maintainable.

Domain Services

When an operation doesn't conceptually belong to any entity or value object, we implement it as a domain service. Domain services express significant domain processes or transformations.

In a banking application, I implemented a fund transfer service:

class FundTransferService {
  constructor(accountRepository, transactionRepository) {
    this.accountRepository = accountRepository;
    this.transactionRepository = transactionRepository;
  }

  async transferFunds(sourceAccountId, destinationAccountId, amount, description) {
    if (amount <= 0) {
      throw new Error('Transfer amount must be positive');
    }

    // Load accounts from repository
    const sourceAccount = await this.accountRepository.findById(sourceAccountId);
    if (!sourceAccount) {
      throw new Error('Source account not found');
    }

    const destinationAccount = await this.accountRepository.findById(destinationAccountId);
    if (!destinationAccount) {
      throw new Error('Destination account not found');
    }

    // Check business rules
    if (!sourceAccount.canWithdraw(amount)) {
      throw new Error('Insufficient funds');
    }

    // Perform the transfer
    sourceAccount.withdraw(amount);
    destinationAccount.deposit(amount);

    // Create transaction records
    const transactionId = generateUniqueId();
    const timestamp = new Date();

    const sourceTransaction = new Transaction(
      generateUniqueId(),
      transactionId,
      sourceAccountId,
      'DEBIT',
      amount,
      description,
      timestamp
    );

    const destinationTransaction = new Transaction(
      generateUniqueId(),
      transactionId,
      destinationAccountId,
      'CREDIT',
      amount,
      description,
      timestamp
    );

    // Save everything
    await this.accountRepository.save(sourceAccount);
    await this.accountRepository.save(destinationAccount);
    await this.transactionRepository.save(sourceTransaction);
    await this.transactionRepository.save(destinationTransaction);

    return {
      transactionId,
      sourceAccount,
      destinationAccount,
      amount,
      timestamp
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Domain services handle operations that involve multiple aggregates or complex business logic that doesn't belong to a single entity.

Applying DDD to Web Application Architecture

I've found that layered architecture works well with DDD principles in web applications:

  1. Presentation Layer: Handles HTTP requests and responses, translates between API formats and domain objects.

  2. Application Layer: Orchestrates use cases, manages transactions, and coordinates domain objects.

  3. Domain Layer: Contains the business model with entities, value objects, aggregates, and domain services.

  4. Infrastructure Layer: Provides implementations for repositories, factories, and external services.

Here's an example of a controller in the presentation layer:

class TaskController {
  constructor(taskService) {
    this.taskService = taskService;
  }

  async createTask(req, res) {
    try {
      const { title, description, dueDate, assigneeId } = req.body;

      const task = await this.taskService.createTask({
        title,
        description,
        dueDate: new Date(dueDate),
        assigneeId
      });

      return res.status(201).json({
        id: task.id,
        title: task.title,
        description: task.description,
        status: task.status,
        dueDate: task.dueDate,
        assigneeId: task.assigneeId
      });
    } catch (error) {
      if (error.name === 'ValidationError') {
        return res.status(400).json({ error: error.message });
      }
      return res.status(500).json({ error: 'Internal server error' });
    }
  }

  async getTaskById(req, res) {
    try {
      const { id } = req.params;
      const task = await this.taskService.getTaskById(id);

      if (!task) {
        return res.status(404).json({ error: 'Task not found' });
      }

      return res.status(200).json({
        id: task.id,
        title: task.title,
        description: task.description,
        status: task.status,
        dueDate: task.dueDate,
        assigneeId: task.assigneeId
      });
    } catch (error) {
      return res.status(500).json({ error: 'Internal server error' });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And an application service in the application layer:

class TaskService {
  constructor(taskRepository, userRepository) {
    this.taskRepository = taskRepository;
    this.userRepository = userRepository;
  }

  async createTask(taskData) {
    // Validate business rules
    if (!taskData.title || taskData.title.trim() === '') {
      throw new ValidationError('Title is required');
    }

    if (taskData.assigneeId) {
      const assignee = await this.userRepository.findById(taskData.assigneeId);
      if (!assignee) {
        throw new ValidationError('Assignee not found');
      }
    }

    // Create the task entity
    const task = new Task(
      null, // ID will be assigned when saved
      taskData.title,
      taskData.description || '',
      'TODO', // Initial status
      taskData.assigneeId,
      taskData.dueDate
    );

    // Save to repository
    await this.taskRepository.save(task);

    return task;
  }

  async getTaskById(id) {
    return this.taskRepository.findById(id);
  }

  async assignTask(taskId, userId) {
    const task = await this.taskRepository.findById(taskId);
    if (!task) {
      throw new NotFoundError('Task not found');
    }

    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new ValidationError('User not found');
    }

    task.assignTo(userId);
    await this.taskRepository.save(task);

    return task;
  }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Benefits of DDD

I've seen several benefits from applying DDD principles in web applications:

  1. Reduced complexity in large systems: By breaking down problems into bounded contexts, even the most complex domains become manageable.

  2. Improved communication: The ubiquitous language bridges the gap between technical and business stakeholders, reducing misunderstandings.

  3. More accurate models: By focusing on the domain rather than technical concerns, we create systems that better reflect business realities.

  4. Better adaptability: DDD systems can evolve as business needs change, as the architecture is aligned with the domain.

  5. Enhanced maintenance: Clear boundaries and separation of concerns make it easier to maintain and extend the application over time.

Implementing Domain-Driven Design in web applications takes effort and commitment. It requires close collaboration with domain experts and a willingness to refine models as understanding deepens. However, I've found that for complex business domains, the investment pays off through reduced maintenance costs, fewer defects, and better alignment with business needs.

After implementing DDD principles in numerous web applications, I'm convinced it's one of the most effective approaches for managing complexity and creating systems that truly serve their intended purpose.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs