Exploring Domain Events in DDD: What They Are and Why They Matter
In this article and the ones to follow, I want to delve into patterns, good practices, and tools from the Domain Driven Design world. These are instrumental in writing code that is not only cleaner but also more efficient and scalable.
Let’s specifically dive into domain events in this discussion, since in the previous article, we discussed that all domain experts should be on the same page during all times of the project, and domain events are going to help us accomplish that in our code.
Domain events are quite literal — they are the events or occurrences within our domain that are then broadcasted to the rest of the domain experts in case these need to react to them. For example, consider this: how would the Marketing service know of a new sale in your e-commerce platform if your sales service doesn’t send them a notification once the payment is processed?
Legacy Systems — How do they look like?
Usually, in legacy systems, we always find messy code where the controllers in the infrastructure layer, are the ones that orchestrate all actions required for each use case. For example, let’s continue with the example of the e-commerce, you have a controller class called AddItemToShoppingCartController.
This controller as its name says, should focus on doing the action that its name says, which is, add the item selected into the shopping cart. But the reality is not like that, we always end up adding in this controller actions such as notifying the marketing team that a new item has been added to the cart so if the user ends up not buying it, maybe they can email the user with the typical email saying “Hey — seems like you have not finished your purchase” or some similar but more persuasive email (I am not a marketing genius).
Also, we may want to notify the Business Intelligence team so they know there is a new lead and also for their own statistics. In the end, you end up with a controller that looks similar to this:
export class AddItemToShoppingCartController {
constructor(
private readonly addItemToShoppingCartHandler: AddItemToShoppingCartHandler,
private readonly marketingEmailSender: MarketingEmailSender,
private readonly salesBITracker: SalesBITracker
) {}
async run(req: Request, res: Response): Promise<void> {
const { item, userId } = req.body;
await this.addItemToShoppingCartHandler.addItem(item, userId);
await this.marketingEmailSender.send(item, userId);
await this.salesBITracker.track(item);
res.sendStatus(200);
}
}
Let’s draw it as a diagram so it is visible how our architecture looks like:
As we can see, in order to return the response back to the client, we need to synchronously process, not only the main action of adding the item to the shopping cart, but to send an email from marketing and track the lead for BI. This, apart from slowing down your application a lot, it can cause several problems. What happens if the item is added successfully to the shopping cart but the marketing tracker fails to track the lead? Do we just return a 200? Do we add a try/catch just for that block? What happens if we keep adding new actions to the controller? Ideally we would want to make sure that we track each and every lead and that our application is as fast as it can be.
This is where domain events come into play. What we are going to do is to execute the main action of adding the item to the cart, and once we have finished doing it successfully, we are going to publish a domain event saying “hey this new item has been added by user X to the cart”, so that those that need to take an action on this event, they will listen it and execute their own actions accordingly.
How do we apply them
Well, domain events can be either published internally through an in-memory event bus, so that it is just handled in the same project as where it has been published, or they can be published externally using message brokers or similar like Kafka, RabbitMQ, Google PubSub, …
In this article I will just cover how to publish events internally through an in-memory event bus, but if you are interested in knowing how to publish them through an external message broker let me know.
So, going back to the previous example, let’s see in a diagram how it would look like applying domain events:
I know it looks bigger than the previous diagram but I promise that the implementation is worth it. The idea here is to add the item in the shopping cart, we emulate this by doing a MySQL operation (it is just an example). Once we have made sure that the operation has been done successfully, then we publish a domain event for example called “ItemToTheCartAdded”.
This event which is merely a JSON object will contain the attributes item and userId and there will be 2 listeners listening to the topic “ItemToTheCartAdded” so that when a new event is published, these listeners will pick up these events and process them (each of them accordingly). The result in code looks like this:
// src > infrastructure > add-item-to-shopping-cart-controller.ts
export class AddItemToShoppingCartController {
constructor(
private readonly addItemToShoppingCartHandler: AddItemToShoppingCartHandler
) {}
async run(req: Request, res: Response): Promise<void> {
const { item, userId } = req.body;
await this.addItemToShoppingCartHandler.addItem(item, userId);
res.sendStatus(200);
}
}
// src > application > add-item-to-shopping-cart-handler.ts
export class AddItemToShoppingCartHandler {
constructor(
private readonly eventBus: EventBus,
private readonly shoppingCartRepository: ShoppingCartRepository
) {}
async addItem(item: Item, userId: string): Promise<void> {
const { item, userId } = req.body;
await this.shoppingCartRepository.addItem(item, userId);
await this.eventBus.publish(new ItemToTheCartAdded({ item, userId }));
}
}
// src > application > listeners > send-email-on-item-added-to-the-cart.ts
export class SendEmailOnItemAddedToTheCart
implements DomainEventSubscriber<ItemToTheCartAddedDomainEvent>
{
constructor(
private readonly marketingEmailSender: MarketingEmailSender,
private readonly userRepository: userRepository
) {}
subscribedTo(): DomainEventClass[] {
return [ItemToTheCartAddedDomainEvent];
}
async on(domainEventPayload: ItemToTheCartAddedDomainEvent) {
const { userId, item } = domainEventPayload;
// Get user email from the userId received
const userEmail = await this.userRepository.getUserEmailById(userId);
// Send the user an email with a reminder of the item left in the cart
await this.marketingEmailSender.sendMissingPurchaseEmail(userEmail, item);
}
}
// src > infrastructure > in-memory-event-bus.ts
export class InMemoryEventBus extends EventEmitter implements EventBus {
constructor() {
super();
}
async publish(events: DomainEvent[]): Promise<void> {
events.map((event) => this.emit(event.eventName, event));
}
addSubscribers(subscribers: Array<DomainEventSubscriber<DomainEvent>>): void {
subscribers.forEach((subscriber) => {
subscriber.subscribedTo().forEach((event) => {
this.on(event.EVENT_NAME, subscriber.on.bind(subscriber));
});
});
}
}
This is just part of the actual implementation. I know there are some pieces missing but you get the point of the example.
There are 2 particular things to point out from this example in the way I did it: the payload of the event (what data it contains), and the place/layer where I decided to publish the domain event (in my case in the use case in the application layer). I am going to address both points individually.
What data should your events contain
Actually this is up to you, my approach is to publish the event with the essential information needed but without leaving all the weight to the publisher. For instance, in the example I publish the event with the item object and the userId, but one of my listeners was the email sender and it obviously needs the user email in order to be able to actually send the email.
I decided that instead of doing the database query before publishing the event, it will be the consumer who will query the db and do other operations. This might depend on each case, as there might be multiple listeners that need the user email address, and it may be worth publishing the email inside the event so all the consumers don’t need to do any query and resources are saved.
It will also depend on the granularity of your events. You may need to publish very specific events like UserPasswordChanged and so, the payload of the event may just include the userId and its new password value (or not even the password). Or you may just use one event for many actions like UserUpdated so if the user changes its username, email, password or whatever, this event is triggered and it will contain basically all the information of the user inside it. It will all depend on the use cases that your project has.
What layer should publish your events
In my case, I have decided to publish the domain event in the use case handler, right after the main action has finished. But actually, there are some people that defend that these events should be published in the actual domain layer as they are as close to the entity being modified or the action being taken as possible. These are implementation details that may vary and are up to you.
I hope you enjoyed the article. If you did, a thumbs-up would mean a lot and help me continue writing about DDD. Thanks a bunch! 😃