A simple Domain Driven Design Toolkit created to help developers to understand how to implement DDD concepts. It also contains some useful stuff not directly related to DDD, like a command bus or result pattern.
This toolkit is not a library, it's just a set of classes and interfaces that you can use, and it has no dependency.
Note: This toolkit is still under development and not intended to be used in production. All classes and interfaces are subject to change.
npm install --save @evyweb/simple-ddd-toolkit
- Aggregate
- Command
- Command handler
- Command bus
- Domain events
- Entity
- Errors
- Event bus
- Middleware
- Query
- Query handler
- Query bus
- Result
- Use case
- Value object
In the book "Implementing Domain-Driven Design" by Vaughn Vernon, a Value Object
is described as an object that measures, quantifies or describes certain aspects of a domain without having a conceptual identity.
The key characteristics of a value object include:
- Immutability: Once created, it cannot be altered. Any change to the properties of a value object should result in the creation of a new object.
- Equality Based on Attributes: Two instances of value objects are considered equal not based on their identity, but if all their properties have the same values.
- Replaceability: They can be replaced by other instances with the same values without disrupting the integrity of the domain.
- Lack of Identity: Value objects do not have a distinct identity that distinguishes them.
- Design by Contract: They can validate conditions that must be true throughout the lifetime of the object (e.g., an email address must contain an "@" symbol).
Address
Email
PhoneNumber
DateRange
Color
Weight
Height
Temperature
Money
- etc...
An Entity
is an object that has a distinct identity that runs throughout its lifecycle. It is defined by its attributes and its identity. An entity can be mutable, and its identity is not based on its attributes.
A Value Object
, on the other hand, is defined by its attributes and not by its identity. It is immutable and can be replaced by another instance with the same values without disrupting the integrity of the domain.
We can say that a Color
is defined by its red
, green
, and blue
values which are numbers.
Once a Color
object is created, it cannot be altered. Any change to the properties of a Color
object should result in the creation of a new color.
That's the combination of the red
, green
, and blue
values that define a color
.
Two colors are considered equal if they have the same amount of red
, green
, and blue
.
A Color
object can be replaced by another instance with the same values.
A Color
object does not have a distinct identity that distinguishes it.
Note that it can depend on the context. For example, in a graphic design application, a color may have an identity if it is used to represent a specific color in a palette.
But let's consider the Color
object as a value object for this example.
A Color
object can validate conditions that must be true throughout its lifetime. For example, the red
, green
, and blue
values must be between 0 and 255.
We can create the color value object by extending the ValueObject
class provided by the simple-ddd-toolkit
package.
import {ValueObject} from "@evyweb/simple-ddd-toolkit";
export class Color extends ValueObject<{ red: number, green: number, blue: number }> {}
You can also create an interface to define the red
, green
, and blue
values.
interface RGBColor {
red: number;
green: number;
blue: number;
}
export class Color extends ValueObject<RGBColor> {}
By default, you will not be able to create an instance of the Color
class because its constructor is protected.
const color = new Color({ red: 255, green: 0, blue: 0 }); // Error
To create a new instance of the Color
class, you need to create a static factory method that will validate the red
, green
, and blue
values before creating the instance.
A possible implementation to do that can be:
import {ValueObject} from "@evyweb/simple-ddd-toolkit";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export class Color extends ValueObject<RGBColor> {
static create({red, green, blue}: RGBColor): Color {
// Validate the red, green, and blue values here
this.validateColorValue(red);
this.validateColorValue(green);
this.validateColorValue(blue);
return new Color({ red, green, blue });
}
private validateColorValue(value: number): void {
if (value < 0 || value > 255) {
throw new Error("RGB color value must be between 0 and 255.");
}
}
}
Now you can create a new instance of the Color
class using the create
method.
const color = Color.create({ red: 255, green: 0, blue: 0 });
By using a factory method, you can ensure that the Color
object is created with valid values.
You can also easily create different static factory methods to create colors based on different criteria.
const color1 = Color.fromRGB({ red: 255, green: 0, blue: 0 });
const color2 = Color.fromHEX('#FF0000');
Note that the fromRGB
and fromHEX
methods are just static methods names, you can choose any name that makes sense for you.
The important thing is that they are static factory methods that create a Color
object and validate the input values before creating the object.
The word 'from' is a common convention to indicate that the method creates an object from a specific format.
Now that the value object is created, you can use the equals
method provided by the ValueObject
class, you can compare two Color
objects.
const color1 = Color.fromRGB({ red: 255, green: 0, blue: 0 });
const color2 = Color.fromHEX('#FF0000');
console.log(color1.equals(color2)); // true
Here is the full implementation of the Color
class:
import {ValueObject} from "@evyweb/simple-ddd-toolkit";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export class Color extends ValueObject<RGBColor> {
static fromRGB({red, green, blue}: RGBColor): Color {
Color.validateRGBColorFormat(red);
Color.validateRGBColorFormat(green);
Color.validateRGBColorFormat(blue);
return new Color({red, green, blue});
}
static fromHEX(hexValue: string): Color {
Color.validateHexColorFormat(hexValue);
return Color.fromRGB({
red: parseInt(hexValue.substring(1, 3), 16),
green: parseInt(hexValue.substring(3, 5), 16),
blue: parseInt(hexValue.substring(5, 7), 16),
});
}
private static validateRGBColorFormat(value: number): void {
if (value < 0 || value > 255) {
throw new Error("RGB color value must be between 0 and 255.");
}
}
private static validateHexColorFormat(hex: string) {
if (!/^#[0-9A-F]{6}$/i.test(hex)) {
throw new Error("Invalid HEX color format.");
}
}
}
When the color object is created, it is automatically immutable. You will not be able to change the red
, green
, and blue
values of the color object.
You can retrieve the red
, green
, and blue
values of the color object using the get
method provided by the ValueObject
class.
const color = Color.fromRGB({red: 255, green: 255, blue: 255});
color.get('red'); // 255
color.get('green'); // 255
color.get('blue'); // 255
You will get autocomplete suggestions for the get
method based on the properties of the Color
class.
As mentioned earlier, a value object is immutable. You cannot change the red
, green
, and blue
values of the color object directly.
You will need to create a new color object with the updated values.
const color = Color.fromRGB({red: 255, green: 255, blue: 255});
const newColor = color.removeRed();
class Color extends ValueObject<RGBColor> {
// ...
removeRed(): Color {
return Color.fromRGB({
red: 0,
green: this.get('green'),
blue: this.get('blue'),
});
}
}
In this example, the removeRed
method creates a new color object with the red
value set to 0 and the green
and blue
values copied from the original color object.
If you want to use a constructor instead of a static factory method, you can simply make the constructor public.
export class Color extends ValueObject<RGBColor> {
constructor({red, green, blue}: RGBColor) {
// Validate the red, green, and blue values here
super({red, green, blue});
}
// Other methods
}
Now you can create a new instance of the Color
class using the constructor directly.
const color = new Color({ red: 255, green: 0, blue: 0 });
It is recommended to avoid nested values in the value object. Try to keep the value object as flat as possible and simple to use. If you need to store complex data, consider creating a separate value object for that data.
The Result
pattern is a way to handle errors and success cases in a more explicit way.
It is a simple pattern that consists of two possible outcomes: Ok
and Fail
.
The Ok
outcome represents a successful operation and contains the result of the operation.
The Fail
outcome represents a failed operation and contains an error object that describes the reason for the failure.
The Result
class provided by the simple-ddd-toolkit
package can be used to create Ok
and Fail
outcomes.
import {Result} from "@evyweb/simple-ddd-toolkit";
const successResult = Result.ok("Operation successful");
const errorResult = Result.fail(new Error("Operation failed"));
You can check if the result is successful using the isOk
method.
if (currentResult.isOk()) {
console.log(currentResult.getValue());
}
You can check if the result is a failure using the isFail
method.
if (currentResult.isFail()) {
console.log(currentResult.getError());
}
Note that you cannot have both a value and an error in the same result object. It is either an Ok
outcome with a value or a Fail
outcome with an error.
Note that you can combine the value object factory method with the result pattern
to return a result object that contains the created value object or an error.
It can be useful to handle validation errors when creating the value object.
interface RGBColor {
red: number;
green: number;
blue: number;
}
class InvalidRGBColorError extends Error {
constructor() {
super('Invalid RGB color format.');
}
}
export class Color extends ValueObject<RGBColor> {
static fromRGB(rgbColor: RGBColor): Result<Color, InvalidRGBColorError> {
if (Color.isInvalidRGBColor(rgbColor)) {
return Result.fail(new InvalidRGBColorError());
}
return Result.ok(new Color(rgbColor));
}
}
const colorCreation = Color.fromRGB({red: 255, green: 255, blue: 255});
if(colorCreation.isOk()) {
// Do something with the color
} else {
// Handle the error
}
Note the return type of the fromRGB
method: Result<Color, InvalidRGBColorError>
.
That means that the fromRGB
method can return an Ok
outcome with a Color
object or a Fail
outcome with an InvalidRGBColorError
object.
In the simple-ddd-toolkit
, errors are represented as classes that extend the Error
class.
To help you create more explicit errors, we gave you 3 classes TechnicalError
, DomainError
and CustomError
that you can extend to create your custom errors.
Note: TechnicalError
and DomainError
extend the CustomError
class.
This way, you can create different types of errors based on the context in which they occur and react differently if the error is a technical error or a domain error.
To create a domain error, you can extend the DomainError
class provided by the simple-ddd-toolkit
package.
import {DomainError} from "@/errors/DomainError";
export class AnyDomainError extends DomainError {
constructor() {
super('Any domain related error message'); // Can be also a translation key
}
}
When you will create the error object, you will have access to two helpers methods isDomainError
and isTechnicalError
to check the type of the error more easily.
const error = new AnyDomainError();
if(error.isDomainError()) {
// Handle domain error
} else if(error.isTechnicalError()) {
// Handle technical error
}
To create a technical error, you can extend the TechnicalError
class provided by the simple-ddd-toolkit
package.
import {TechnicalError} from "@/errors/TechnicalError";
export class AnyTechnicalError extends TechnicalError {
constructor() {
super('Any technical related error message'); // Can be also a translation key
}
}
When you will create the error object, you will have access to two helpers methods isDomainError
and isTechnicalError
to check the type of the error more easily.
const error = new AnyTechnicalError();
if(error.isDomainError()) {
// Handle domain error
} else if(error.isTechnicalError()) {
// Handle technical error
}
The CustomError
class provided by the simple-ddd-toolkit
package can be used to create custom errors.
import {CustomError} from "@/errors/CustomError";
export class AnyCustomError extends CustomError {
constructor() {
super('Any custom error message'); // Can be also a translation key
}
isDomainError(): boolean {
return false;
}
isTechnicalError(): boolean {
return true;
}
}
When you define the custom error class, you will need to override the isDomainError
and isTechnicalError
methods to specify the type of error.
An Entity
is an object encapsulating domain logic and data, and it has a distinct identity that runs throughout its lifecycle.
"We design a domain concept as an Entity
when we care about its individuality, when distinguishing it from all other objects in a system is a mandatory constraint.
An entity is a unique thing and is capable of being changed continuously over a long period of time" - Vaughn Vernon
It is the unique identity and mutability that distinguishes an Entity
from a Value Object
.
Value objects can serve as holders of unique identity. They are immutable, which ensures identity stability, and any behavior specific to the kind of identity is centralized.
User
Order
Product
Customer
Account
Invoice
To create an entity, you can extend the Entity
class provided by the simple-ddd-toolkit
package.
import {Entity} from "@evyweb/simple-ddd-toolkit";
interface UserData {
id: UUID;
name: string;
}
export class User extends Entity<UserData> {
static create(userData: UserData): User {
// Validation rules here
return new User(userData);
}
}
Here UUID is a type that represents a universally unique identifier. It is exposed by the simple-ddd-toolkit
package as a ready to use Value Object.
Just like the ValueObject
class, the Entity
class has a protected constructor, which means you cannot create an instance of the User
class directly.
const user = new User({ id: UUID.create(), name: 'John Doe' }); // Error
To create a new instance of the User
class, you need to use a static factory method.
const user = User.create({ id: UUID.create(), name: 'John Doe' });
This way, you can ensure that the entity is created with valid data.
Similarly to the ValueObject
class, the Entity
factory functions can be combined with the Result
pattern to handle validation errors.
import {Result} from "@evyweb/simple-ddd-toolkit";
interface UserData {
id: UUID;
name: string;
}
class InvalidUserNameError extends DomainError {
constructor() {
super('Username cannot contain special characters.');
}
}
export class User extends Entity<UserData> {
static create(userData: UserData): Result<User, InvalidUserError> {
if (User.isInvalidUserName(userData.name)) {
return Result.fail(new InvalidUserNameError());
}
return Result.ok(new User(userData));
}
private static isInvalidUserName(name: string): boolean {
return /[^a-zA-Z0-9]/.test(name);
}
}
In this example, the create
method returns a Result
object that contains either a User
entity or an InvalidUserNameError
error.
You can retrieve the id
and name
values of the user entity using the get
method provided by the Entity
class.
const user = User.create({ id: UUID.create(), name: 'John Doe' });
user.get('id'); // UUID
user.get('name'); // 'John Doe'
Note that the id
returned by the get
method is a UUID
value object.
To get the actual value of the UUID
object, you can use the get('value')
method provided by the UUID
class.
const userId = user.get('id').get('value');
You can also use the shortcut method id()
to get the id
value directly.
const userId = user.id(); // Similar to user.get('id').get('value')
An entity is mutable, which means you can change its properties directly.
const user = User.create({ id: UUID.create(), name: 'John Doe' });
user.set('name', 'Jane Doe');
In this example, the name
property of the user entity is updated to 'Jane Doe'.
Entities are compared based on their identity, not their attributes.
const userId = UUID.create();
const user1 = User.create({ id: userId, name: 'John Doe' });
const user2 = User.create({ id: userId, name: 'Jane Doe' });
console.log(user1.equals(user2)); // true
In this example, even though the name
properties of the two user entities are different, they are considered as the same because they have same identities.
You can convert an entity to a plain JavaScript object using the toObject
method provided by the Entity
class.
const user = User.create({ id: UUID.create(), name: 'John Doe' });
user.toObject(); // { id: '...', name: 'John Doe' }
The toObject
method returns an object with the properties of the entity.
An Aggregate
is a cluster of domain objects that can be treated as a single unit.
It is an important concept in Domain-Driven Design (DDD) that helps to maintain consistency and integrity in the domain model.
An aggregate has the following characteristics:
- Root Entity: An aggregate has a root entity that acts as the entry point to the aggregate. The root entity is responsible for maintaining the consistency of the aggregate.
- Boundary: An aggregate defines a boundary within which all domain objects are consistent with each other. The root entity enforces the consistency of the aggregate by controlling access to its internal objects.
- Transaction: An aggregate is treated as a single unit in a transaction. All changes to the aggregate are made atomically, ensuring that the aggregate remains in a consistent state.
- Identity: An aggregate has a unique identity that distinguishes it from other aggregates in the system.
- Encapsulation: An aggregate encapsulates its internal objects and exposes only the root entity to the outside world.
- Invariants: An aggregate enforces invariants that must be true for the aggregate to be in a valid state.
To create an aggregate, you can extend the Aggregate
class provided by the simple-ddd-toolkit
package.
import {Aggregate} from "@evyweb/simple-ddd-toolkit";
interface OrderData {
id: UUID;
items: OrderItem[];
date: Date;
}
export class Order extends Aggregate<OrderData> {
static create(orderData: OrderData): Order {
// Validation rules here
return new Order(orderData);
}
addItem(productId: string, quantity: number): void {
if (this.get('items').length >= 10) {
throw new Error("An order cannot contain more than 10 items.");
}
const item = OrderItem.create({productId, quantity});
this.get('items').push(item);
}
}
const order = await orderRepository.getById('order-id');
order.addItem('product1', 2);
orderRepository.save(order);
In this example, the Order
class extends the Aggregate
class and defines a create
method to create a new order.
The addItem
method adds a new item to the order. It checks if the order already contains 10 items and throws an error if the limit is reached.
The Order
class can be used to create and manage orders in the domain model.
The Order
is then saved to the repository using the save
method provided by the repository.
An aggregate can emit domain events to notify other parts of the system about changes in its state.
To create a domain event, you can extend the DomainEvent
class provided by the simple-ddd-toolkit
package.
import {DomainEvent} from "@evyweb/simple-ddd-toolkit";
interface Metadata {
orderId: string;
productId: string;
quantity: string;
}
export class ProductAddedToOrderEvent extends DomainEvent<Metadata> {
constructor(
public readonly orderId: string,
public readonly productId: string,
public readonly quantity: number
) {
super();
}
}
In this example, the ProductAddedToOrderEvent
class extends the DomainEvent
class and defines the metadata for the event.
When creating a DomainEvent
, the following data are available and can be override if needed:
- eventId: A unique identifier for the event.
- eventType: The type of the event, by default it is the constructor name.
- occurredOn: The date and time when the event occurred.
- metadata: Additional data related to the event.
To emit domain events from an aggregate, you need to add the events to the queue first.
To do so, you can use the addEvent
method provided by the Aggregate
class.
const order = await orderRepository.getById('order-id');
order.addItem('product1', 2);
order.addEvent(new ProductAddedToOrderEvent(order.id(), 'product1', 2));
orderRepository.save(order);
eventBus.dispatchEvents(order.getEvents());
Then you need to dispatch the events to the event bus using the dispatchEvents
method provided by the event bus.
You need to inject the event bus into the commandHandler to be able to use it.
export class AddProductToOrderCommandHandler extends CommandHandler<AddProductToOrderCommand, void> {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventBus: EventBus
) {
super();
}
async handle(command: AddProductToOrderCommand): Promise<void> {
const order = await this.orderRepository.getById(command.orderId);
order.addItem(command.productId, command.quantity);
order.addEvent(new ProductAddedToOrderEvent(order.id(), command.productId, command.quantity));
this.orderRepository.save(order);
this.eventBus.dispatchEvents(order.getEvents());
}
}
In this example, the AddProductToOrderCommandHandler
class injects the event bus and dispatches the events after saving the order.
The event bus is responsible for dispatching the events to the appropriate event handlers.
A Command
is a request to perform an action or change the state of the system.
It encapsulates the data required to perform the action and is sent to a Command Handler
to execute the action.
The Command Handler
is responsible for processing the command and updating the system's state accordingly.
To create a command, you can extend the Command
class provided by the simple-ddd-toolkit
package.
import {Command} from "@evyweb/simple-ddd-toolkit";
export class CreateCharacterCommand extends Command {
public readonly __TAG = 'CreateCharacterCommand';
public readonly name: string;
constructor(name: string) {
super();
this.name = name;
}
}
To create a command handler, you can extend the CommandHandler
class provided by the simple-ddd-toolkit
package.
import {CommandHandler} from "@evyweb/simple-ddd-toolkit";
export class CreateCharacterCommandHandler extends CommandHandler<CreateCharacterCommand, void> {
async handle(command: CreateCharacterCommand): Promise<void> {
// Process the command here
}
}
Most of the time, a command handler will not return anything, so the second type parameter of the CommandHandler
class is void
.
But it can return a value if needed (e.g., the id of the created element).
Commands are dispatched to the appropriate command handler using a Command Bus
.
To dispatch a command, you can use the execute
method provided by the command bus.
const command = new CreateCharacterCommand(name);
await commandBus.execute(command);
The command bus is responsible for routing the command to the correct command handler and executing the handler.
To register a command handler with the command bus, you can use the register
method provided by the command bus.
commandBus.register(CreateCharacterCommand, () => new CreateCharacterCommandHandler());
You can also use an ioc container (like inversify or simple-ddd-toolkit) to resolve the command handler.
commandBus.register(CreateCharacterCommand, () => container.get(DI.CreateCharacterCommandHandler));
A Query
is a request for data from the system.
It encapsulates the data required to retrieve information and is sent to a Query Handler
to fetch the data.
The Query Handler
is responsible for processing the query and returning the requested information.
To create a query, you can extend the Query
class provided by the simple-ddd-toolkit
package.
import {Query} from "@evyweb/simple-ddd-toolkit";
export class LoadCharacterCreationDialogQuery extends Query {
public readonly __TAG = 'LoadCharacterCreationDialogQuery';
}
To create a query handler, you can extend the QueryHandler
class provided by the simple-ddd-toolkit
package.
import {QueryHandler} from "@evyweb/simple-ddd-toolkit";
export class LoadCharacterCreationDialogQueryHandler extends QueryHandler<LoadCharacterCreationDialogQuery, LoadCharacterCreationDialogResponse> {
async handle(_query: LoadCharacterCreationDialogQuery): Promise<LoadCharacterCreationDialogResponse> {
// Data can be fetched from a database, an API, or any other source
return {
title: 'Add a new character',
subTitle: 'Fill out the form to create a new character.',
form: {
avatar: {
label: 'Avatar',
required: false,
value: '/images/avatars/default.png'
},
name: {
label: 'Name *',
placeholder: 'Character name',
required: true,
value: ''
},
submit: {
label: 'Validate'
},
cancel: {
label: 'Cancel'
}
}
}
}
}
The LoadCharacterCreationDialogQueryHandler
class extends the QueryHandler
class and defines the response type as LoadCharacterCreationDialogResponse
.
The response is also known as a ViewModel.
interface CharacterCreationFormViewModel {
avatar: {
label: string;
required: boolean;
value: string;
};
name: {
label: string;
placeholder: string;
required: boolean;
value: string;
};
submit: {
label: string;
};
cancel: {
label: string;
};
}
export interface LoadCharacterCreationDialogResponse {
title: string;
subTitle: string;
form: CharacterCreationFormViewModel;
}
Queries are dispatched to the appropriate query handler using a Query Bus
.
To dispatch a query, you can use the execute
method provided by the query bus.
const query = new LoadCharacterCreationDialogQuery();
const response = await queryBus.execute(query);
The query bus is responsible for routing the query to the correct query handler and executing the handler.
To register a query handler with the query bus, you can use the register
method provided by the query bus.
queryBus.register(LoadCharacterCreationDialogQuery, () => new LoadCharacterCreationDialogQueryHandler());
You can also use an ioc container (like inversify or simple-ddd-toolkit) to resolve the query handler.
queryBus.register(LoadCharacterCreationDialogQuery, () => container.get(DI.LoadCharacterCreationDialogQueryHandler));
Middleware is a way to add additional behavior to commands and queries without modifying the core logic.
It allows you to intercept commands and queries before they are processed by the command or query handler.
You can create middlewares for both commands and queries by extending the CommandMiddleware
and QueryMiddleware
classes provided by the simple-ddd-toolkit
package.
import {CommandMiddleware} from "./CommandMiddleware";
import {Logger} from "@/logger/Logger";
import {Command} from "@/bus/command/Command";
export class CommandLoggingMiddleware implements CommandMiddleware {
constructor(
private readonly logger: Logger,
private readonly middlewareId: string
) {}
async execute<Response>(command: Command, next: (command: Command) => Promise<Response>): Promise<Response> {
const date = new Date().toISOString();
this.logger.log(`[${date}][${this.middlewareId}][${command.__TAG}] - ${JSON.stringify(command)}`);
return next(command);
}
}
import {QueryMiddleware} from "./QueryMiddleware";
import {Logger} from "@/logger/Logger";
import {IResponse} from "@/bus/query/IResponse";
import {Query} from "@/bus/query/Query";
export class QueryLoggingMiddleware implements QueryMiddleware {
constructor(
private readonly logger: Logger,
private readonly middlewareId: string
) {
}
execute(query: Query, next: (query: Query) => Promise<IResponse>): Promise<IResponse> {
const date = new Date().toISOString();
this.logger.log(`[${date}][${this.middlewareId}][${query.__TAG}] - ${JSON.stringify(query)}`);
return next(query);
}
}
In these examples, the CommandLoggingMiddleware
and QueryLoggingMiddleware
classes log the command or query data before passing it to the next middleware or the command/query handler.
To register middleware with the command bus or query bus, you can use the use
method provided by the bus.
commandBus.use(new CommandLoggingMiddleware(logger, 'CommandLoggingMiddleware'));
queryBus.use(new QueryLoggingMiddleware(logger, 'QueryLoggingMiddleware'));
But you can also use an ioc container to resolve the middleware.
commandBus.use(container.get(DI.CommandLoggingMiddleware));
queryBus.use(container.get(DI.QueryLoggingMiddleware));
The Event Bus
is a way to decouple components in a system by allowing them to communicate through events.
It provides a mechanism for publishing and subscribing to events, allowing different parts of the system to react to changes without being tightly coupled.
Similarly to the command bus and query bus, the event bus is responsible for routing events to the appropriate event handlers.
import {EventBus} from "@evyweb/simple-ddd-toolkit";
eventBus.on('ConversationCreatedEvent', () => new CreateDefaultPostEventHandler());
You can also use an ioc container to resolve the event handler.
eventBus.on('ConversationCreatedEvent', () => container.get(DI.CreateDefaultPostEventHandler));
You can group all the event types in a single file to avoid typos or to group them by domain.
export const EventTypes = {
ConversationCreatedEvent: 'ConversationCreatedEvent',
PostCreatedEvent: 'PostCreatedEvent',
// ...
};
eventBus.on(EventTypes.ConversationCreatedEvent, () => new CreateDefaultPostEventHandler());
To create an event handler, you can implement the EventHandler
interface provided by the simple-ddd-toolkit
package.
export class CreateDefaultPostEventHandler implements IEventHandler<ConversationCreatedEvent> {
constructor(private readonly commandBus: Bus<Command>) {
}
async handle(event: ConversationCreatedEvent): Promise<void> {
const {conversationId, characterId, postId, userId, participantsIds} = event.metadata;
const command = new CreateDefaultPostCommand(conversationId, userId, characterId, postId, participantsIds);
await this.commandBus.execute(command);
}
}
In this example, the CreateDefaultPostEventHandler
class implements the IEventHandler
interface and defines the handle
method to process the event.
The event handler can execute commands, queries, or any other logic based on the event data.
Here the event handler creates a default post when a conversation is created.
To dispatch events to the event bus, you can use the dispatch
and dispatchAsync
methods provided by the event bus.
const event = new ProductAddedToOrderEvent(order.id(), 'product1', 2);
// Use dispatch if you want to wait for the event to be processed before continuing (also present on aggregates)
await eventBus.dispatch(event);
// if you want to dispatch events without blocking the user, prefer dispatchAsync (also present on aggregates)
// Don't use: "eventBus.dispatch(event);" without await
// Use:
eventBus.dispatchAsync(event);
The dispatch
method has to be executed with an await
, as it is synchronous and will wait for the event to be processed before continuing.
The dispatchAsync
method is asynchronous, which means it will dispatch the event without waiting for it to be processed.
Using dispatchAsync
can be useful when you want to dispatch events without blocking the main thread. For example, when you want to dispatch events in the background.
You can use for eventual consistency.
Under the hood, the dispatchAsync
method uses the setImmediate
function to dispatch events asynchronously.
Why not use Promise.resolve().then(() => eventBus.dispatch(event))
or process.nextTick(() => eventBus.dispatch(event))
or remove the await
from eventBus.dispatch(event)
?
The setImmediate
function is a more reliable way to dispatch events asynchronously because it ensures that the event is dispatched after the current phase of the event loop has completed. Tasks added via setImmediate
are placed in the macrotask queue (specifically in the "check" phase), whereas tasks from Promise.resolve
or process.nextTick
are placed in the microtask queue.
This distinction is crucial because the microtask queue is processed before the event loop moves to the next phase, which means that using Promise.resolve
or process.nextTick
can introduce unintended blocking if the tasks are computationally expensive or numerous. In contrast, setImmediate
ensures that the current phase of the event loop (including microtasks) is entirely finished before the event is dispatched, reducing the risk of blocking or performance degradation.
While setTimeout
is similar to setImmediate
, it schedules the task in the timer queue, which is processed after a minimum delay and only after the next phase of the event loop. setImmediate
schedules the task in the check queue, allowing it to be executed earlier than a setTimeout
task.