DEV Community

minericefield
minericefield

Posted on • Edited on

[DDD] Tactical Design Patterns Part 1: Domain Layer

I will be applying Domain-Driven Design (DDD) tactical design patterns in this article.
I'm sorry. I'm not good at English.
It is divided into the following sections:

  1. Domain Layer (this article)
  2. Application Layer
  3. Presentation/Infrastructure Layer

To keep the description of source code in the article compact, I will be implementing it from a bottom-up approach.

GitHub Repository

https://github.com/minericefield/ddd-onion-lit

Language & Framework

TypeScript & NestJS

Architecture

Onion Architecture

Situation

An organization has decided to create a task management application for internal use (though this situation is unusual to apply DDD).
Though focusing on tactical design patterns, I have prepared a simple use case diagram and a simple domain model diagram.

Something like a use case model diagram

use case model diagram

Users can perform several basic operations on tasks. It may seem unusual, but I haven't included features such as changing the progress status of tasks or setting deadlines. I omitted them because it didn't appear there would be much to learn from implementing those features in the sample code.
Regarding the actor responsible for creating users, we don't have a clear understanding yet. For now, there is a consensus to avoid a situation where everyone can freely create users.
Agilely, we will proceed with the implementation while leaving notes and discussion memos in diagrams for undecided aspects. However, an unknown actor is a quite rare situation.

Something like a domain model diagram

domain model diagram

I have documented various constraints, along with the behavior and multiplicity of each entity. The specification of limiting comments on a task to 20 may seem a bit unusual, but I included it as a valuable constraint to emphasize the learning theme.
Ideally, I would have liked to provide details about value objects, but I have omitted them this time.
The domain model diagram will be created after developing the use case diagram. This is because lacking a clear understanding of the use cases makes it challenging to conduct appropriate domain modeling. However, the representations in the domain model diagram will capture fundamental invariants that extend beyond specific use cases.

Directory Structure

While I'd like to proceed with the implementation starting from the domain layer, let's first check the directory structure to have a rough idea of the architecture.

src
 ┣ application
 ┃ ┣ auth
 ┃ ┣ shared
 ┃ ┣ task
 ┃ ┗ user
 ┣ domain
 ┃ ┣ shared
 ┃ ┣ task
 ┃ ┗ user
 ┣ infrastructure
 ┃ ┣ in-memory
 ┃ ┣ mysql
 ┃ ┗ uuid
 ┗ presentation
   ┣ http
   ┗ nest-commander
Enter fullscreen mode Exit fullscreen mode

The structure follows an Onion Architecture ("presentation" can also be named as "user-interface").
Some may suggest organizing it in a feature-based slice when using NestJS (such as having src/task/domain or src/task/application). While that is also a valid approach, this time I referred to the module structure of IDDD for inspiration.
com.saasovation.agilepm.domain.model.product
No concepts of organizations like com.saasovation or context boundaries like agilepm are present. If I were to insist, the product aligns directly with the context of task management. What I took inspiration from are the modules that follow, such as domain and application. Within these, specific top-level module and aggregates come into play.

Domain Layer

This is where we express the business logic.

Exceptions

I've defined common exception classes for the domain layer.

export class ValidationDomainException extends Error {}

export class UnexpectedDomainException extends Error {}
Enter fullscreen mode Exit fullscreen mode

For now, I've introduced validation and unexpected exceptions. These are simple extensions of standard exceptions. Domain layer exceptions primarily point to violations of business rules.
While these exceptions don't have a direct relationship with the domain model, they will become necessary in the implementation of the domain layer. These are concepts that do not depend on specific protocols etc.
Unexpected exceptions might be used alongside defensive programming practices. However, particularly in the domain layer, it's crucial to avoid being overly defensive, as it could blur the representation of business rules. Their usage might be quite limited.

User Email Address Value Object

export class UserEmailAddress {
  private static readonly USER_EMAIL_ADDRESS_PATTERN =
    /^[a-zA-Z0-9_+-]+(\.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;

  private _value: string;

  constructor(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }
    this._value = value;
  }

  get value() {
    return this._value;
  }
}

export class InvalidUserEmailAddressFormatException extends ValidationDomainException {
  constructor() {
    super(`Invalid email address format.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

I have created a value object for the user's email address. This is a minimal implementation that provides only validation and encapsulation of the value. The constructor is made public as is.

Let's go through this implementation because there seem to be some strange points.

Is a private setter necessary for the value?

In IDDD, a private setter was provided for each property of domain objects, and validation was performed within the setter (self-delegation). Especially in the case of domain objects with multiple constructors or properties, encapsulating the constraints of each property within setters through self-delegation would result in a simpler and more extensible implementation.
This time, I didn't provide a private setter because there wasn't a particular advantage to self-delegation, and I opted for simplicity by only providing a constructor.

Is a specific exception class necessary?

I provided a specific exception class (InvalidUserEmailAddressFormatException) to indicate a format violation of the email address. However, this is not a mandatory concept in DDD.
I just referenced the implementation of creating specific exception classes for particular exceptions from the "Good Code, Bad Code: Think like a software engineer".

  • Specific and descriptive exception classes contribute to improvement of readability and error identification.
  • They allow encapsulation of behavior specific to individual exceptions.
    • Setting error messages.
    • Providing metadata useful for debugging.
    • (However, be careful not to make it feel unnatural in the representation of the domain layer).

Furthermore, particularly for domain layer exceptions, it's beneficial to have them easily identifiable. This is because many use cases invoke behaviors from multiple domain objects. While it's not advisable to be overly concerned about external layers when implementing the domain layer, providing specific exception classes enables use cases or their clients to flexibly respond based on the received exception.

Is the term "Validation" appropriate?

The InvalidUserEmailAddressFormatException extends ValidationDomainException, but the term "Validation" might not be precisely suitable. In IDDD, validation was categorized as guards for each property, distinctly separated from validation. A guard, in this case, is an assertion about the validity of each parameters, and the following code snippet aligns with this concept:

if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
  throw new InvalidUserEmailAddressFormatException();
}
Enter fullscreen mode Exit fullscreen mode

In IDDD, exceptions thrown by guards were IllegalArgumentException, indicating that the argument is invalid. The concept of validation, did not appear here.
Validation typically refers to cross-object validation, such as deferred validation traversing all properties within an entity. This validation's implementation details are placed as a separate resource, distinct from domain objects.
But this time, I have chosen a simple approach, broadly expressing any concept that validates individual values or other invariants as "Validation". Additionally, I have not included what is conventionally referred to as a validator in IDDD (In IDDD, a validator, unlike guards that immediately throw exceptions, traverses the object, aggregates all errors, and notifies them collectively).

A few other implementation patterns

Equality Check

Implementing the behavior of equality checking is a common practice, and it comes with several benefits:

  • Clients can confirm equality without needing in-depth knowledge of the internal structure of the value object.
  • Maintainability will be raised.
  • By overriding comparison operators, you can express comparisons between value objects naturally (Not available in TypeScript).
    • Something like FooValueObjectA == FooValueObjectB.
    • Value objects represent conceptually unified "values," and such a representation feels more natural.

While the email address created in this example is a simple value object with only one primitive property, the format of value objects can vary.

class Name {
  constructor(
    private readonly firstName: string,
    private readonly lastName: string,
  ) {}

  equals(name: Name) {
    return this.firstName === name.firstName && this.lastName === name.lastName;
  }
}
Enter fullscreen mode Exit fullscreen mode

If clients repeatedly reference properties directly for comparisons, there's a risk of oversight when adding properties to the value object. In contrast, by using equals, modifications can be consolidated in one place.
(For example, if a middle name is added)

class Name {
  constructor(
    private readonly firstName: string,
    private readonly lastName: string,
    private readonly middleName: string,
  ) {}

  equals(name: Name) {
    return (
      this.firstName === name.firstName &&
      this.lastName === name.lastName &&
      this.middleName === name.middleName // Compare by middlename.
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This time, I have only implemented the minimum behavior required in the source for value objects. However, equality verification based on property (and type) comparison is one of the fundamental characteristics of value objects. In practice, you might implement the behavior of equality verification, e.g., by preparing a layer supertype with equality verification behavior.

Avoiding Validation during Reconstitution

During creation of a new object, a FACTORY should simply balk when an invariant isn’t met, but a more flexible response may be necessary in reconstitution. If an object already exists somewhere in the system (such as in the database), this fact cannot be ignored.
(Domain-Driven Design: Tackling Complexity in the Heart of Software)

Addressing this issue can be approached in various ways. One example is delegating the responsibility of validation to the factory.

class UserEmailAddress {
  private static readonly USER_EMAIL_ADDRESS_PATTERN =
    /^[a-zA-Z0-9_+-]+(\.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;

  constructor(readonly value: string) {}

  static assertEmailAddressPatternValid(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }
  }
}

class CreateUser {
  handle(emailAddress: string) {
    UserEmailAddress.assertEmailAddressPatternValid(emailAddress);

    return new User(new UserEmailAddress(emailAddress));
  }
}

class ReconstituteUser {
  handle(emailAddress: string) {
    return new User(new UserEmailAddress(emailAddress));
  }
}
Enter fullscreen mode Exit fullscreen mode

In the reconstitution factory (ReconstituteUser), validation is omitted.
Alternatively, each value object class itself can hold factory interfaces for both creation and reconstitution.

class UserEmailAddress {
  private static readonly USER_EMAIL_ADDRESS_PATTERN =
    /^[a-zA-Z0-9_+-]+(\.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;

  private constructor(readonly value: string) {}

  static create(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }

    return new UserEmailAddress(value);
  }

  static reconstitute(value: string) {
    return new UserEmailAddress(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

However, the reconstitute as an interface within the value object class itself may feel somewhat impure. It might make sense for aggregate roots to have an interface called reconstitute. This is because reconstitution is basically the concept of reassembling aggregate roots. The distinction between the creation and reconfiguration of objects inside the boundary is just an implementation that may be needed in the process, and it may be prohibited to have it as an interface for the value object class itself.
In any case, it may be necessary to consider validation during reconstitution.

(The term "invariant")

While the email address format, as seen in my example, can be broadly regarded as part of the invariants, the term "invariant" is less commonly used in this this kind of context. It is more frequently employed when describing intricate business rules that need to be consistently maintained within an aggregate.

User ID Value Object

Next, I created a value object for the user ID.

export class UserId {
  _userIdBrand!: never;

  constructor(readonly value: string) {}
}
Enter fullscreen mode Exit fullscreen mode

For now, the only expectation for a user ID is uniqueness; there are no other specific requirements.
In languages with a structural type system, like TypeScript, it can be beneficial to use a brand property for such simple entity IDs.
In IDDD, the generation of user IDs was handled by the Repository. The implementation class of the Repository can generate IDs using specific persistence mechanisms or data store functions or as seen in the sample code, use a bulit-in module. But some may find it slightly awkward that a LevelDBRepository doesn't utilize LevelDB functionalities in this approach. And it may be preferable that the return value of the repository always be the aggregate root.
This time, I have opted to generate IDs through a factory.

export abstract class UserIdFactory {
  abstract handle(): UserId;
}
Enter fullscreen mode Exit fullscreen mode

The implementation of the UserIdFactory could be functionalities such as RDB features or UUID frameworks. This allows choosing the most suitable implementation for the requirements.

class RdbUserIdFactory implements UserIdFactory {
  handle(): UserId {
    // Use RDB
  }
}

class UuidFrameworkUserIdFactory implements UserIdFactory {
  handle(): UserId {
    // Use UUID framework
  }
}
Enter fullscreen mode Exit fullscreen mode

User Aggregate Root

Now that the internal objects within the boundary are in place, let's create the aggregate root.

export class User {
  constructor(
    readonly id: UserId,
    readonly name: string,
    readonly emailAddress: UserEmailAddress,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Currently, the user has no specific behavior. A user is an entity with an identifier called "id" and also serves as the aggregate root. The constructor is public and expects domain objects to be passed directly. Therefore, external resources outside the aggregate will create internal objects within the boundary and pass them to the exposed constructor. This time, for simple aggregate roots like this, we generate them following this construction approach.

User Repository

Now that we have the aggregate root, let's create the corresponding repository. Several necessary interfaces are declared:

export abstract class UserRepository {
  abstract insert(user: User): Promise<void>;
  abstract find(): Promise<User[]>;
  abstract findOneById(id: UserId): Promise<User | undefined>;
  abstract findOneByEmailAddress(
    userEmailAddress: UserEmailAddress,
  ): Promise<User | undefined>;
}
Enter fullscreen mode Exit fullscreen mode

This abstract class outlines the essential behavior that the UserRepository should provide.

Email Address Duplication Check Domain Service

Confirming duplication by asking the email address or the user itself is not feasible as a modeling approach. Users don't have knowledge of email addresses other than their own, nor should they.

export class UserEmailAddressIsNotDuplicated {
  constructor(private readonly userRepository: UserRepository) {}

  /**
   * @throws {DuplicatedUserEmailAddressException}
   */
  async handle(userEmailAddress: UserEmailAddress) {
    if (await this.userRepository.findOneByEmailAddress(userEmailAddress)) {
      throw new DuplicatedUserEmailAddressException();
    }
  }
}

export class DuplicatedUserEmailAddressException extends ValidationDomainException {
  constructor() {
    super(`User email address is duplicated.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Email address duplication confirmation is expressed through a domain service (defining only the abstraction is also an option). Clients will use this domain service to check for duplication before generating the user aggregate root.
It's common to see domain services that return a boolean result for duplication checks. While Evans suggests that domain services should return domain objects, in cases like this domain service, returning a boolean might be more natural and straightforward as an interface.
The reason I chose to throw an exception in this implementation is to express that email address duplication is a clear violation of a business rule. Using a boolean return value might lack expressive power in representing a business rule violation, and it might force the client side into a somewhat procedural implementation.
On the other hand, using void is also somewhat awkward. Therefore, considering:

  • A Result type to represent business rule violations
  • A some kind of universal domain object to represent the existence or duplication status

could be a good alternative for return value.

Enforcing Invariants with a Factory

It might be an option to generate the user aggregate root through a factory, enforcing invariants.

export class UserFactory {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly userIdFactory: UserIdFactory,
  ) {}

  async handle(name: string, emailAddress: string) {
    const userEmailAddress = new UserEmailAddress(emailAddress);
    if (await this.userRepository.findOneByEmailAddress(userEmailAddress)) {
      throw new DuplicatedUserEmailAddressException();
    }

    const userId = this.userIdFactory.handle();

    return new User(userId, name, userEmailAddress);
  }
}
Enter fullscreen mode Exit fullscreen mode

I've created a concrete factory that focuses on generating a new user based on a name and email address for simplicity. In real practice, factories are more often abstracted to handle tasks like ID generation and querying persistence infrastructure.
The reasons for introducing a factory and the implementation methods vary, but enforcing invariants is a crucial theme in the context of factories. It's worth noting that there are two main types of factories:

  • Factories representing ubiquitous language
  • Factories necessary for implementation

In this case, the UserFactory falls into the category of Factories necessary for implementation.
In the case of the former domain service, the knowledge that "email addresses must not be duplicated" is appropriately expressed and encapsulated in the domain layer. However, the situation of "checking for duplication in the domain service before generating a user" still represents a leakage of responsibility from the domain layer, and clients are forced into an unstable procedural implementation. By encapsulating the generation process, including enforcing invariants, within the factory, such situations can be avoided.

(Protecting Access Using Modules)

Modules help organize related concepts into easily understandable structures, reduce the coupling between modules, and provide functionality to restrict access to available resources. In Java, modules can be implemented using packages, and in C#, using namespaces. However, TypeScript doesn't have these features. Ideally, we would like to define modules and restrict the invocation of aggregate root generation methods so that they can only be called from factories.

Next, I will create the domain.task module.

Comment ID Value Object

export class CommentId {
  _commentIdBrand!: never;

  constructor(readonly value: string) {}
}

export abstract class CommentIdFactory {
  abstract handle(): CommentId;
}
Enter fullscreen mode Exit fullscreen mode

Same implementation as UserId.

Comment Entity

export class Comment {
  constructor(
    readonly id: CommentId,
    readonly userId: UserId,
    readonly content: string,
    readonly postedAt: Date,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Comments have a UserId (objects within the boundary can hold references to other aggregate roots). Expressing the association with the aggregate root through ID references is a technique introduced in IDDD. It can make entities more compact and easy to handle while also contributing to performance improvements.
Here, a dependency across modules has occurred (although the module itself has not been implemented in TypeScript, a directory structure resembling modules is used for representation).

import { UserId } from '../../user/user-id.value-object';
Enter fullscreen mode Exit fullscreen mode

However, this is an acceptable dependency. There are some alternatives, but none of them are recommended. Let's briefly go through them.

Place UserId in shared

Placing any shared object in the shared directory can make it unclear what concept the resource originates from. UserId should be a resource explicitly included in the user module. The shared directory is intended for more abstract shared objects or universal concepts that are not related to a specific domain.

Place a concrete generic EntityId in shared and have each entity use it directly

Something like this.

class EntityId {
  constructor(readonly value: string) {}
}

class User {
  constructor(
    readonly id: EntityId,
  ) {}
}

class Comment {
  constructor(
    readonly userId: EntityId,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

However, couldn’t the team have achieved loose coupling among these elements by the use of a generic identity type,
(...) True, the team could have achieved looser coupling. However, it would also have opened up the potential for bugs in code where each Identity type could not be distinguished from the others.
(Implementing Domain-Driven Design)

As mentioned in IDDD, we might pass the wrong type of ID.

Considering the reasons mentioned above, it's acceptable to directly couple UserId.

Comment Entity First-Class Collection

export class Comments {
  private static readonly COMMENT_NUMBER_LIMIT = 20;

  constructor(private readonly _value: Comment[]) {}

  get value() {
    return [...this._value].sort(
      (commentA, commentB) =>
        -(commentA.postedAt.getTime() - commentB.postedAt.getTime()),
    );
  }

  add(comment: Comment) {
    if (this._value.length >= Comments.COMMENT_NUMBER_LIMIT) {
      throw new CommentNumberExceededException(Comments.COMMENT_NUMBER_LIMIT);
    }

    return new Comments([...this._value, comment]);
  }
}

export class CommentNumberExceededException extends ValidationDomainException {
  constructor(commentNumberLimit: number) {
    super(`Can't add more than ${commentNumberLimit} comments.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The invariant conditions related to the collection of comments can be directly expressed by the task entity. However, encapsulating them in a first-class collection can achieve better cohesion, resulting in a simpler and more understandable implementation of the task entity.

Always want to see the list of comments in descending order

get value() {
  return [...this._value].sort(
    (commentA, commentB) =>
      -(commentA.postedAt.getTime() - commentB.postedAt.getTime()),
  );
}
Enter fullscreen mode Exit fullscreen mode

In general, we prefer not to express display-related responsibilities in domain objects. However, in this case, the requirement is to always have comments in descending order, not limited to a specific user interface. And with minimal control over the order. For tasks like transforming domain objects into a specific structure that can be exposed externally or converting them into a format that is easy for specific end-users to read, these responsibilities might belong to DTOs or the presentation layer.
This time, I chose to implement the requirement for descending order directly within the first-class collection, treating it as a high-level requirement outlined on the domain model diagram.
There might be feeling some resistance to giving get value such behavior, so it could also be a good idea to define other getters.

get value() {
  return [...this._value];
}

get inDescendingOrder() {
  return [...this._value].sort(
    (commentA, commentB) =>
      -(commentA.postedAt.getTime() - commentB.postedAt.getTime()),
  );
}
Enter fullscreen mode Exit fullscreen mode

The maximum number of comments for one task is 20

add(comment: Comment) {
  if (this._value.length >= Comments.COMMENT_NUMBER_LIMIT) {
    throw new CommentNumberExceededException(Comments.COMMENT_NUMBER_LIMIT);
  }

  return new Comments([...this._value, comment]);
}
Enter fullscreen mode Exit fullscreen mode

If there are already 20 comments, an exception is thrown when trying to add a new one.

Task Name Value Object

export class TaskName {
  private static readonly TASK_NAME_CHARACTERS_LIMIT = 50;

  private _value: string;

  constructor(value: string) {
    if (value.length > TaskName.TASK_NAME_CHARACTERS_LIMIT) {
      throw new TaskNameCharactersExceededException(
        TaskName.TASK_NAME_CHARACTERS_LIMIT,
      );
    }
    this._value = value;
  }

  get value() {
    return this._value;
  }
}

export class TaskNameCharactersExceededException extends ValidationDomainException {
  constructor(taskNameCharactersLimit: number) {
    super(
      `Task name can't be longer than ${taskNameCharactersLimit} characters.`,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

If the task name exceeds 50 characters, an exception will be thrown.

Task ID Value Object

export class TaskId {
  _taskIdBrand!: never;

  constructor(readonly value: string) {}
}

export abstract class TaskIdFactory {
  abstract handle(): TaskId;
}
Enter fullscreen mode Exit fullscreen mode

Same implementation as other entity ids.

Task Aggregate Root

export class Task {
  private constructor(
    readonly id: TaskId,
    readonly name: TaskName,
    private _comments: Comments,
    private _userId?: UserId,
  ) {}

  get comments() {
    return this._comments.value;
  }

  get userId() {
    return this._userId;
  }

  static create(id: TaskId, name: TaskName) {
    return new Task(id, name, new Comments([]));
  }

  static reconstitute(
    id: TaskId,
    name: TaskName,
    comments: Comment[],
    userId?: UserId,
  ) {
    return new Task(id, name, new Comments(comments), userId);
  }

  addComment(comment: Comment) {
    this._comments = this._comments.add(comment);
  }

  assignUser(userId: UserId) {
    this._userId = userId;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Task aggregate root encompasses various behaviors that align with the ubiquitous language:

  • Adding a comment - addComment
  • Assigning a user - assignUser

The constructor is made private, and interfaces for creation and reconstitution are provided:

  • Creation - static create
    • No comments
    • Not assigned to anyone
  • Reconstitution - static reconstitute
    • This requires self-discipline, as it should ideally be used only by the repository.

Task Repository

export abstract class TaskRepository {
  abstract insert(task: Task): Promise<void>;
  abstract update(task: Task): Promise<void>;
  abstract find(): Promise<Task[]>;
  abstract findOneById(id: TaskId): Promise<Task | undefined>;
}
Enter fullscreen mode Exit fullscreen mode

A repository corresponding to the aggregate root is defined for tasks.

Resources

Top comments (0)