Domain-Driven Design Patterns: An Introduction

8 minute read Published: 2023-07-29


What this post is, and what is isn't.

This post is:

This post is not:

Domain-driven design (DDD) is an approach I've taken on various projects historical and one that I'm still not sure I've entirely mastered. There is a lot of nuance to DDD, namely the matter of buy-in from stakeholders across the organization, truly working hand-in-hand with domain experts, assuming you have them- and if you don't, then also building up that expertise in your team- and finally championing that mentality across all of the silos in your workplace. Everyone needs to be on board, and that's just from a strategic design standpoint. Practicing domain-driven design is hard, much like software engineering can be in general, but it's also a foreign concept to many developers out there.

The core of domain-driven design, in my own words:

Domain-driven design is about designing your software in the way the business domain is structured, from the terminology used by each context of your product to the naming of your programming constructs.

By most DDD practicioners' standards: your domain experts should be able to understand what's happening in your code without being a programmer. This relates only to the domain portion of your code, and in many architectural approachs to software such as [hexagonal architecture], you would have that complete separation of the core domain logic from any application or infrastructure logic. These often pair well DDD, and there are many examples of implementing tactical DDD patterns online.

Reading Resources

The absolute classics are the original Eric Evans blue book, Domain-Driven Design: Tackling Complexity in the Heart of Software, and the Vaughn Vernon red book Implementing Domain-Driven Design.

Identifying the structure of the business domain

This is the part I mentioned this post would NOT be. I'll only cover some terminology that will be useful within this post:

That is a lot to gather without much context, and if you are interested in the strategic design elements you should read more on it from the blue book and/or red book.

Domain Objects

Even in 2003, Evans' classified some of the still relevant types of domain objects you'll find in domain-driven design. These classifications include:

Types from other patterns such as enterprise architecture, layered architecture, design patterns and more have been mostly adopted into domain-driven design as well, and you'll commonly see many of the following:

Depending on which DDD tactical design patterns you implement, you may also end up seeing terminology like CQRS. We'll hold off on diving any deeper for now.

Applied Domain Patterns

The following adapts some of the code from a Destiny bot project a friend of mine was working on. The code was originally in JavaScript at the time and I thought I would convert it to utilize more domain-driven design patterns instead.

By my standards, this is still a work in progress, but it's a start.

/// Domain layer: Value Objects, Entities, Aggregates

export abstract class ValueObject<T> {
  constructor(public readonly value: T) {}
}

export class StringValueObject extends ValueObject<string> {}

export class NumberValueObject extends ValueObject<number> {}


type Hex = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'|'a'|'b'|'c'|'d'|'e'|'f';
type Hex2 = `${Hex}${Hex}`;
type Hex3 = `${Hex2}${Hex}`;
type Hex4 = `${Hex2}${Hex2}`;
type Hex8 = `${Hex4}${Hex4}`;
type Hex12 = `${Hex8}${Hex4}`;
type FOUR = '4';
type AB89 = 'a'|'b'|'8'|'9';
type UuidV4 = `${Hex8}-${Hex4}-${FOUR}${Hex3}-${AB89}${HEX3}-${HEX12}`;

export class Uuid extends ValueObject<UuidV4> {
  private static readonly REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

  constructor(value: UuidV4) {
    super(value);

    this.validate();
  }

  private validate(): void {
    assert(Uuid.REGEX.test(this.value), 'Value was not a valid Version 4 UUID');
  }
}

export class Email extends StringValueObject {
  private static readonly REGEX = /^[^@]+@[^@]+$/i;

  constructor(value: Email) {
    super(value);

    this.validate();
  }

  private validate(): void {
    assert(Email.REGEX.test(this.value), 'Value was not a valid email address');
  }
}

export class Username extends StringValueObject {
  private static readonly REGEX = /^[^@]+@[^@]+$/i;

  constructor(value: Username) {
    super(value);

    this.validate();
  }

  private validate(): void {
    assert(Username.REGEX.test(this.value), 'Value was not a valid email address');
  }
}

// Entities & Aggregates

export interface Entity {
  public id: Uuid;
}

export class Entity implements Entity {
  constructor(public readonly id: Uuid) {}
}

export type Aggregate = Entity;

export class Aggregate extends Entity {}

export class User implements Aggregate {
  constructor(
    public readonly id: Uuid,
    public readonly email: Email,
    public readonly firstName: StringValueObject,
    public readonly lastName: StringValueObject,
    public readonly username: Username,
  ) {
    super(id);
  }
}

/// Domain Repositories

// A collection-oriented repository:
interface Repository<T extends Entity> {
  add(model: T): Promise<void>;
  update(model: T): Promise<void>;
  delete(model: T): Promise<void>;
}

// A collection-oriented repository:
interface PersistenceRepository<T> extends Repository<T> {
  persist(): Promise<void>; 
}

interface UserRepository extends Repository<User> {
  existsByUsername(username: string): Promise<boolean>;
}

/// Infrastructure layer: We implement our interfaces 

interface MongooseSchema<T> {
  exists(criteria: Partial<T>): Command<boolean>;
  updateOne(
    criteria: Partial<T>,
    operation: MongooseOperation<T>,
    callback: (error?: Error) => any
  ): Promise<void>;
}

interface MongooseOperation<T> {
  $set: Partial<T>;
}

interface Command<T> {
  exec(): Promise<T>;
}

interface UserSchema {
  id: string,
  email: string,
  firstName: string,
  lastName: string,
  username: string,
}

interface PersistenceObject {
  save(): Promise<void>;
}

type PersistenceObjectFactory<T> = (model: T) => PersistenceObject;

export class MongoUserRepository implements UserRepository {
  constructor(
    private readonly schema: MongooseSchema<UserSchema>,
    private readonly factory: PersistenceObjectFactory<User>,
    private readonly logger: Logger,
  ) { }

  async add(user: User): Promise<void> {
    const userModel = this.factory(user);

    await userModel.save();
  }

  async update(user: User): Promise<void> {
    const updateModel = {
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      username: user.username,
    };

    return this.schema.updateOne(
      { id: user.id },
      { $set: updateModel },
    )
  }

  async delete(user: User) {
    return this.schema.deleteOne({ id: user.id });
  }

  async existsByUsername(username: string): Promise<boolean> {
    return this.schema.exists({ username }).exec() ? true : false;
  }
}