Decoupling Logic with Domain Events [Guide] - Domain-Driven Design w/ TypeScript
We cover this topic in The Software Essentialist online course. Check it out if you liked this post.
Introduction
When we're working on backend logic, it's not uncommon to eventually find yourself using language like this to describe what should happen.
"When __ happens, then do ___".
And "after this, do that".
Or, "after this, and that, and then this... but only when this, do that".
Just all kinds of noise, right?
Chaining logic can get really messy.
Here's what I mean.
When someone registers for an account on White Label, the open-source Vinyl-Trading DDD application I'm building, I want to do several things.
I want to:
- Create the user's account
- Uniquely assign that user a default username based on their actual name
- Send a notification to my Slack channel letting me know that someone signed up
- Add that user to my Mailing List
- Send them a Welcome Email
... and I'll probably think of more things later as well.
How would you code this up?
A first approach might be to do all of this stuff in a UsersService
, because that's where the main event (creating the User
) is taking place, right?
import { SlackService } from '../../notification/services/slack';
import { MailchimpService } from '../../marketing/services/mailchimp';
import { SendGridService } from '../../notification/services/email';
class UsersService {
private sequelizeModels: any;
constuctor (sequelizeModels: any) {
this.sequelizeModels = sequelizeModels;
}
async createUser (
email: string, password: string, firstName: string, lastName: string
): Promise<void> {
try {
// Assign a username (also, might be taken)
const username = `${firstName}${lastName}`
// Create user
await sequelizeModels.User.create({
email, password, firstName, lastName, username
});
// Send notification to slack channel
await SlackService.sendNotificatation (
`Hey guys, ${firstName} ${lastName} @ ${email} just signed up.`
);
// Add user to mailing list
await MailchimpService.addEmail(email)
// Send a welcome email
const welcomeEmailTitle = `Welcome to White Label`
const welcomeEmailText = `Hey, welcome to the hottest place to trade vinyl.`
await SendGridService.sendEmail(email, welcomeEmailTitle, welcomeEmailText);
} catch (err) {
console.log(err);
}
}
}
The humanity! You probably feel like this right now.
Alright, what's wrong with this?
Lots. But the main things are:
- The
UsersService
knows too much about things that aren't related toUsers
. Sending emails and slack messages most likely should belong to theNotifications
subdomain, while hooking up marketing campaigns using a tool like Mailchimp would make more sense to belong to aMarketing
subdomain. Currently, we've coupled all of the unrelated side-effects ofcreateUser
to theUsersService
. Think about how challenging it will be in order to isolate and test this class now.
We can fix this.
There's a design principle out there that's specifically useful for times like this.
Design Principle: "Strive for loosely coupled design against objects that interact". - via solidbook.io: The Software Architecture & Design Handbook
This decoupling principle is at the heart of lots of one of my favourite libraries, RxJs.
The design pattern it's built on is called the observer pattern.
In this article, we'll learn how to use Domain Events in order to decouple complex business logic.
Prerequisites
In order to get the most out of this guide, you should know the following:
- What Domain-Driven Design is all about.
- Understand the role of entities, value objects, aggregates, and repositories.
- How logically separating your app into Subdomains and Use Cases helps you to quickly understand where code belongs and enforce architectural boundaries.
- And (optionally), you've read the previous article on Domain Events.
What are Domain Events?
Every business has key events that are important.
In our vinyl-trading application, within the vinyl
subdomain, we have events like VinylCreated
, VinylUpdated
, and VinylAddedToWishList
.
In a job seeking application, we might see events like JobPosted
, AppliedToJob
, or JobExpired
.
Despite the domain that they belong to, when domain events are created and dispatched, they provide the opportunity for other (decoupled) parts of our application to execute some code after that event.
Actors, Commands, Events, and Subscriptions
In order to determine all of the capabilities of an app, an approach is to start by identifying the actors, commands, events, and responses to those events (subscriptions).
- Actors: Who or what is this relevant person (or thing) to the domain? -
Authors
,Editors
,Guest
,Server
- Commands: What can they do? -
CreateUser
,DeleteAccount
,PostArticle
- Event: Past-tense version of the command (verb) -
UserCreated
,AccountDeleted
,ArticlePosted
- Subscriptions: Classes that are interested in the domain event that want to be notified when they occurred -
AfterUserCreated
,AfterAccountDeleted
,AfterArticlePosted
In the DDD-world, there's a fun activity you can do with your team to discover all of these. It's called Event Storming and it involves using sticky notes in order to discover the business rules.
At the end of this process, depending on the size of your company, you'll probably end up with a huge board full of stickies.
At this point, we'll probably have a good understanding of who does what, what the commands are, what the policies are the govern when someone can perform a particular command, and what happens in response to those commands as subscriptions to domain events.
Let's apply that to our CreateUser
command at a smaller scale.
Uncovering the business rules
Alright, so any anonymous user is able to create an account. So an anonymous user
should be able to execute the CreateUser
command.
The subdomain this command belongs to would be the Users
subdomain.
Don't remember how subdomains work? Read this article first.
OK. Now, what are the other things we want to happen in response to the UserCreated
event that would get created and then dispatched?
Let's look at the code again.
import { SlackService } from '../../notification/services/slack';
import { MailchimpService } from '../../marketing/services/mailchimp';
import { SendGridService } from '../../notification/services/email';
class UsersService {
private sequelizeModels: any;
constuctor (sequelizeModels: any) {
this.sequelizeModels = sequelizeModels;
}
async createUser (
email: string, password: string, firstName: string, lastName: string
): Promise<void> {
try {
// Create user (this is ALL that should be here)
await sequelizeModels.User.create({
email, password, firstName, lastName
});
// Subscription side-effect (Users subdomain): Assign user username
// Subscription side-effect (Notifications subdomain): Send notification to slack channel
// Subscription side-effect (Marketing subdomain): Add user to mailing list
// Subscription side-effect (Notifications subdomain): Send a welcome email
} catch (err) {
console.log(err);
}
}
}
Alright, so we have:
- Subscription side-effect #1:
Users
subdomain - Assign user username - Subscription side-effect #2:
Notifications
subdomain - Send notification to slack channel - Subscription side-effect #3:
Marketing
subdomain - Add user to mailing list - Subscription side-effect #4:
Notifications
subdomain - Send a welcome email
Great, so if were were to visualize the subdomains as modules (folders) in a monolith codebase, this is what the generalization would look like:
Actually, we're missing something.
Since we need to assign a username to the user after the UserCreated
event (and since that operation belongs to the Users
subdomain), the visualization would look more like this:
Yeah. Sounds like a good plan. And let's start from scratch instead of using this anemic UsersService
.
Want to skip to the finished product? Fork the repo for White Label, here.
An explicit Domain Event interface
We'll need an interface in order to depict what a domain event looks like. It won't need much other than the time it was created at, and a way to get the aggregate id.
import { UniqueEntityID } from "../../core/types";
export interface IDomainEvent {
dateTimeOccurred: Date;
getAggregateId (): UniqueEntityID;
}
It's more of an intention revealing interface than anything that actually does something. Half the battle in fighting confusing and complex code is using good names for things.
How to define Domain Events
A domain event is a "plain ol' TypeScript object". Not much to it other than it needs to implement the interface which means providing the date, the getAggregateId (): UniqueEntityID
method and any other contextual information that might be useful for someone who subscribes to this domain event to know about.
In this case, I'm passing in the entire User
aggregate.
Some will advise against this, but for this simple example, you should be OK.
import { IDomainEvent } from "../../../../core/domain/events/IDomainEvent";
import { UniqueEntityID } from "../../../../core/domain/UniqueEntityID";
import { User } from "../user";
export class UserCreated implements IDomainEvent {
public dateTimeOccurred: Date;
public user: User;
constructor (user: User) {
this.dateTimeOccurred = new Date();
this.user = user;
}
getAggregateId (): UniqueEntityID {
return this.user.id;
}
}
How to create domain events
Here's where it gets interesting. The following class is the User
aggregate root. Read through it. I've commented the interesting parts.
import { AggregateRoot } from "../../../core/domain/AggregateRoot";
import { UniqueEntityID } from "../../../core/domain/UniqueEntityID";
import { Result } from "../../../core/logic/Result";
import { UserId } from "./userId";
import { UserEmail } from "./userEmail";
import { Guard } from "../../../core/logic/Guard";
import { UserCreatedEvent } from "./events/userCreatedEvent";
import { UserPassword } from "./userPassword";
// In order to create one of these, you need to pass
// in all of these props. Non-primitive types are Value Objects
// that encapsulate their own validation rules.
interface UserProps {
firstName: string;
lastName: string;
email: UserEmail;
password: UserPassword;
isEmailVerified: boolean;
profilePicture?: string;
googleId?: number; // Users can register w/ google
facebookId?: number; // and facebook (instead of email + pass)
username?: string;
}
// User is a subclass of AggregateRoot. We'll look at the AggregateRoot
// class again shortly.
export class User extends AggregateRoot<UserProps> {
get id (): UniqueEntityID {
return this._id;
}
get userId (): UserId {
return UserId.caller(this.id)
}
get email (): UserEmail {
return this.props.email;
}
get firstName (): string {
return this.props.firstName
}
get lastName (): string {
return this.props.lastName;
}
get password (): UserPassword {
return this.props.password;
}
get isEmailVerified (): boolean {
return this.props.isEmailVerified;
}
get profilePicture (): string {
return this.props.profilePicture;
}
get googleId (): number {
return this.props.googleId;
}
get facebookId (): number {
return this.props.facebookId;
}
get username (): string {
return this.props.username;
}
// Notice that there aren't setters for everything?
// There are only setters for things that it makes sense
// for there for be setters for, like `username`.
set username (value: string) {
this.props.username = value;
}
// The constructor is private so that it forces you to use the
// `create` Factory method. There's no way to circumvent
// validation rules that way.
private constructor (props: UserProps, id?: UniqueEntityID) {
super(props, id);
}
private static isRegisteringWithGoogle (props: UserProps): boolean {
return !!props.googleId === true;
}
private static isRegisteringWithFacebook (props: UserProps): boolean {
return !!props.facebookId === true;
}
public static create (props: UserProps, id?: UniqueEntityID): Result<User> {
// Here are things that cannot be null
const guardedProps = [
{ argument: props.firstName, argumentName: 'firstName' },
{ argument: props.lastName, argumentName: 'lastName' },
{ argument: props.email, argumentName: 'email' },
{ argument: props.isEmailVerified, argumentName: 'isEmailVerified' }
];
if (
!this.isRegisteringWithGoogle(props) &&
!this.isRegisteringWithFacebook(props)
) {
// If we're not registering w/ a social provider, we also
// need `password`.
guardedProps.push({ argument: props.password, argumentName: 'password' })
}
// Utility that checks if anything is missing
const guardResult = Guard.againstNullOrUndefinedBulk(guardedProps);
if (!guardResult.succeeded) {
return Result.fail<User>(guardResult.message)
}
else {
// Create the user object and set any default values
const user = new User({
...props,
username: props.username ? props.username : '',
}, id);
// If the id wasn't provided, it means that we're creating a new
// user, so we should create a UserCreatedEvent.
const idWasProvided = !!id;
if (!idWasProvided) {
// Method from the AggregateRoot parent class. We'll look
// closer at this.
user.addDomainEvent(new UserCreated(user));
}
return Result.ok<User>(user);
}
}
}
View this file on GitHub.
When we use the factory method to create the User, depending on if the User is new (meaning it doesn't have an identifier yet) or it's old (and we're just reconsistuting it from persistence), we'll create the UserCreated
domain event.
Let's look a little closer at what happens when we do user.addDomainEvent(new UserCreated(user));
.
That's where we're creating/raising the domain event.
We need to go to the AggregateRoot
class to see what we do with this.
Handling created/raised domain events
If you remember from our previous chats about aggregates and aggregate roots, the aggregate root in DDD is the domain object that we use to perform transactions.
It's the object that we refer to from the outside in order to change anything within it's invariant boundary.
That means that anytime a transaction that wants to change the aggregate in some way (ie: a command getting executed), it's the aggregate that is responsible for ensuring that all the business rules are satified on that object and it's not in an invalid state.
It says,
"Yes, all good! All my invariants are satisfied, you can go ahead and save now."
Or it might say,
"Ah, no- you're not allowed to add more than 3
Genres
to aVinyl
aggregate. Not OK."
Hopefully, none of that is new as we've talked about that on the blog already.
What's new is how we handle those created/raised domain events.
Here's the aggregate root class.
Check out the protected addDomainEvent (domainEvent: IDomainEvent): void
method.
import { Entity } from "./Entity";
import { IDomainEvent } from "./events/IDomainEvent";
import { DomainEvents } from "./events/DomainEvents";
import { UniqueEntityID } from "./UniqueEntityID";
// Aggregate root is an `abstract` class because, well- there's
// no such thing as a aggregate in and of itself. It needs to _be_
// something, like User, Vinyl, etc.
export abstract class AggregateRoot<T> extends Entity<T> {
// A list of domain events that occurred on this aggregate
// so far.
private _domainEvents: IDomainEvent[] = [];
get id (): UniqueEntityID {
return this._id;
}
get domainEvents(): IDomainEvent[] {
return this._domainEvents;
}
protected addDomainEvent (domainEvent: IDomainEvent): void {
// Add the domain event to this aggregate's list of domain events
this._domainEvents.push(domainEvent);
// Add this aggregate instance to the DomainEventHandler's list of
// 'dirtied' aggregates
DomainEvents.markAggregateForDispatch(this);
}
public clearEvents (): void {
this._domainEvents.splice(0, this._domainEvents.length);
}
private logDomainEventAdded (domainEvent: IDomainEvent): void {
...
}
}
When we call addDomainEvent(domainEvent: IDomainEvent)
, we:
- add that domain event to a list of events that this aggregate has seen so far, and
- we tell something called
DomainEvents
to markthis
for dispatch.
Almost there, let's see how the DomainEvents
class handles domain events.
The handler of domain events (DomainEvents class)
This was pretty tricky.
My implementation of this is something I ported to TypeScript from Udi Dahan's 2009 blog post about Domain Events in C#.
Here it is in it's entirety.
import { IDomainEvent } from "./IDomainEvent";
import { AggregateRoot } from "../AggregateRoot";
import { UniqueEntityID } from "../UniqueEntityID";
export class DomainEvents {
private static handlersMap = {};
private static markedAggregates: AggregateRoot<any>[] = [];
/**
* @method markAggregateForDispatch
* @static
* @desc Called by aggregate root objects that have created domain
* events to eventually be dispatched when the infrastructure commits
* the unit of work.
*/
public static markAggregateForDispatch (aggregate: AggregateRoot<any>): void {
const aggregateFound = !!this.findMarkedAggregateByID(aggregate.id);
if (!aggregateFound) {
this.markedAggregates.push(aggregate);
}
}
/**
* @method dispatchAggregateEvents
* @static
* @private
* @desc Call all of the handlers for any domain events on this aggregate.
*/
private static dispatchAggregateEvents (aggregate: AggregateRoot<any>): void {
aggregate.domainEvents.forEach((event: IDomainEvent) => this.dispatch(event));
}
/**
* @method removeAggregateFromMarkedDispatchList
* @static
* @desc Removes an aggregate from the marked list.
*/
private static removeAggregateFromMarkedDispatchList (aggregate: AggregateRoot<any>): void {
const index = this.markedAggregates
.findIndex((a) => a.equals(aggregate));
this.markedAggregates.splice(index, 1);
}
/**
* @method findMarkedAggregateByID
* @static
* @desc Finds an aggregate within the list of marked aggregates.
*/
private static findMarkedAggregateByID (id: UniqueEntityID): AggregateRoot<any> {
let found: AggregateRoot<any> = null;
for (let aggregate of this.markedAggregates) {
if (aggregate.id.equals(id)) {
found = aggregate;
}
}
return found;
}
/**
* @method dispatchEventsForAggregate
* @static
* @desc When all we know is the ID of the aggregate, call this
* in order to dispatch any handlers subscribed to events on the
* aggregate.
*/
public static dispatchEventsForAggregate (id: UniqueEntityID): void {
const aggregate = this.findMarkedAggregateByID(id);
if (aggregate) {
this.dispatchAggregateEvents(aggregate);
aggregate.clearEvents();
this.removeAggregateFromMarkedDispatchList(aggregate);
}
}
/**
* @method register
* @static
* @desc Register a handler to a domain event.
*/
public static register(
callback: (event: IDomainEvent) => void,
eventClassName: string
): void {
if (!this.handlersMap.hasOwnProperty(eventClassName)) {
this.handlersMap[eventClassName] = [];
}
this.handlersMap[eventClassName].push(callback);
}
/**
* @method clearHandlers
* @static
* @desc Useful for testing.
*/
public static clearHandlers(): void {
this.handlersMap = {};
}
/**
* @method clearMarkedAggregates
* @static
* @desc Useful for testing.
*/
public static clearMarkedAggregates(): void {
this.markedAggregates = [];
}
/**
* @method dispatch
* @static
* @desc Invokes all of the subscribers to a particular domain event.
*/
private static dispatch (event: IDomainEvent): void {
const eventClassName: string = event.constructor.name;
if (this.handlersMap.hasOwnProperty(eventClassName)) {
const handlers: any[] = this.handlersMap[eventClassName];
for (let handler of handlers) {
handler(event);
}
}
}
}
How to register a handler to a Domain Event?
To register a handler to Domain Event, we use the static register
method.
export class DomainEvents {
private static handlersMap = {};
private static markedAggregates: AggregateRoot<any>[] = [];
...
public static register(
callback: (event: IDomainEvent) => void,
eventClassName: string
): void {
if (!this.handlersMap.hasOwnProperty(eventClassName)) {
this.handlersMap[eventClassName] = [];
}
this.handlersMap[eventClassName].push(callback);
}
...
}
It accepts both a callback
function and the eventClassName
, which is the name of the class (we can get that using Class.name
).
When we register a handler for a domain event, it gets added to the handlersMap
.
For 3 different domain events and 7 different handlers, the data structure for the handler's map can end up looking like this:
{
"UserCreated": [Function, Function, Function],
"UserEdited": [Function, Function],
"VinylCreated": [Function, Function]
}
How 'bout an example of a handler?
AfterUserCreated subscriber (notifications subdomain)
Remember when mentioned that we want a subscriber from within the Notifications
subdomain to send us a Slack message when someone signs up?
Here's an example of an AfterUserCreated
subscriber setting up a handler to the UserCreated
event from within the User
subdomain.
import { IHandle } from "../../../core/domain/events/IHandle";
import { DomainEvents } from "../../../core/domain/events/DomainEvents";
import { UserCreatedEvent } from "../../users/domain/events/userCreatedEvent";
import { NotifySlackChannel } from "../useCases/notifySlackChannel/NotifySlackChannel";
import { User } from "../../users/domain/user";
export class AfterUserCreated implements IHandle<UserCreated> {
private notifySlackChannel: NotifySlackChannel;
constructor (notifySlackChannel: NotifySlackChannel) {
this.setupSubscriptions();
this.notifySlackChannel = notifySlackChannel;
}
setupSubscriptions(): void {
// Register to the domain event
DomainEvents.register(this.onUserCreated.bind(this), UserCreated.name);
}
private craftSlackMessage (user: User): string {
return `Hey! Guess who just joined us? => ${user.firstName} ${user.lastName}\n
Need to reach 'em? Their email is ${user.email}.`
}
// This is called when the domain event is dispatched.
private async onUserCreatedEvent (event: UserCreated): Promise<void> {
const { user } = event;
try {
await this.notifySlackChannel.execute({
channel: 'growth',
message: this.craftSlackMessage(user)
})
} catch (err) {
}
}
}
View it on GitHub: Psst. It's here too. Check out the folder structure to see all the use cases.
The loose coupling that happens here's is awesome. It leaves the responsibility of keeping track of who needs to be alerted when a domain event is dispatched, to the DomainEvents
class, and removes the need for us to couple code between Users
and Notifications
directly.
Not only is that good practice, it might very well be necessary! Like, when we get into designing microservices.
Microservices
When we've split our application not only logically, but physically as well (via microservices), it's actually impossible for us to couple two different subdomains together.
We should be mindful of that when we're working on monolith codebases that we might want to someday graduate to microservives.
Be mindful of those architectural boundaries between subdomains. They should know very little about each other.
How does it work in a real-life transaction?
So we've seen how to register a handler from a subscriber to a domain event.
And we've seen how an aggregate root can create, pass, and store the domain event in an array within the DomainEvents
class using addDomainEvent(domainEvent: IDomainEvent)
until it's ready to be dispatched.
markedAggregates = [User, Vinyl]
What are we missing?
At this point, there are a few more questions I had:
- How do we handle failed transactions? What if we tried to execute the
CreateUser
use case, but it failed before the transaction succeeded? It looks like the domain event still gets created. How do we prevent it from getting sent off to subscribers if the transaction fails? Do we need a Unit of Work pattern? - Who's responsibility is it to dictate when the domain event should be sent to all subscribers? Who calls
dispatchEventsForAggregate(id: UniqueEntityId)
?
Separating the creation from the dispatch of the domain event
When a domain event is created, it's not dispatched right away.
That domain event goes onto the aggregate, then the aggregate gets marked in DomainEvents
array.
console.log(user.domainEvents) // [UserCreated]
The DomainEvents
class then waits until something tells it to dispatch all the handlers within the markedAggregates
array that match a particular aggregate id.
The question is, who's responsibility is it to say when the transaction was successful?
Your ORM is the single source of truth for a successful transaction
That's right.
The thing is, a lot of these ORMs actually have mechanisms built in to execute code after things get saved to the database.
For example, the Sequelize docs has hooks for each of these lifecycle events.
(1)
beforeBulkCreate(instances, options)
beforeBulkDestroy(options)
beforeBulkUpdate(options)
(2)
beforeValidate(instance, options)
(-)
validate
(3)
afterValidate(instance, options)
- or -
validationFailed(instance, options, error)
(4)
beforeCreate(instance, options)
beforeDestroy(instance, options)
beforeUpdate(instance, options)
beforeSave(instance, options)
beforeUpsert(values, options)
(-)
create
destroy
update
(5)
afterCreate(instance, options)
afterDestroy(instance, options)
afterUpdate(instance, options)
afterSave(instance, options)
afterUpsert(created, options)
(6)
afterBulkCreate(instances, options)
afterBulkDestroy(options)
afterBulkUpdate(options)
We're interested in the ones in (5).
And TypeORM has a bunch entity listeners which are effectively the same thing.
- @AfterLoad
- @BeforeInsert
- @AfterInsert
- @BeforeUpdate
- @AfterUpdate
- @BeforeRemove
- @AfterRemove
Again, we're mostly interested in the ones that happen afterwards.
For example, if the CreateUserUseCase
like the one shown below transaction suceeds, it's right after the repository is able to create or update the User
that the hook gets invoked.
import { UseCase } from "../../../../core/domain/UseCase";
import { CreateUserDTO } from "./CreateUserDTO";
import { Either, Result, left, right } from "../../../../core/logic/Result";
import { UserEmail } from "../../domain/userEmail";
import { UserPassword } from "../../domain/userPassword";
import { User } from "../../domain/user";
import { IUserRepo } from "../../repos/userRepo";
import { CreateUserErrors } from "./CreateUserErrors";
import { GenericAppError } from "../../../../core/logic/AppError";
type Response = Either<
GenericAppError.UnexpectedError |
CreateUserErrors.AccountAlreadyExists |
Result<any>,
Result<void>
>
export class CreateUserUseCase implements UseCase<CreateUserDTO, Promise<Response>> {
private userRepo: IUserRepo;
constructor (userRepo: IUserRepo) {
this.userRepo = userRepo;
}
async execute (req: CreateUserDTO): Promise<Response> {
const { firstName, lastName } = req;
const emailOrError = UserEmail.create(req.email);
const passwordOrError = UserPassword.create({ value: req.password });
const combinedPropsResult = Result.combine([
emailOrError, passwordOrError
]);
if (combinedPropsResult.isFailure) {
return left(
Result.fail<void>(combinedPropsResult.error)
) as Response;
}
// Domain event gets created internally, here!
const userOrError = User.create({
email: emailOrError.getValue(),
password: passwordOrError.getValue(),
firstName,
lastName,
isEmailVerified: false
});
if (userOrError.isFailure) {
return left(
Result.fail<void>(combinedPropsResult.error)
) as Response;
}
const user: User = userOrError.getValue();
const userAlreadyExists = await this.userRepo.exists(user.email);
if (userAlreadyExists) {
return left(
new CreateUserErrors.AccountAlreadyExists(user.email.value)
) as Response;
}
try {
// If this transaction succeeds, we the afterCreate or afterUpdate hooks
// get called.
await this.userRepo.save(user);
} catch (err) {
return left(new GenericAppError.UnexpectedError(err)) as Response;
}
return right(Result.ok<void>()) as Response;
}
}
Hooking into succesful transactions with Sequelize
Using Sequelize, we can define a callback function for each hook that takes the model name and the primary key field in order to dispatch the events for the aggregate.
import models from '../models';
import { DomainEvents } from '../../../core/domain/events/DomainEvents';
import { UniqueEntityID } from '../../../core/domain/UniqueEntityID';
const dispatchEventsCallback = (model: any, primaryKeyField: string) => {
const aggregateId = new UniqueEntityID(model[primaryKeyField]);
DomainEvents.dispatchEventsForAggregate(aggregateId);
}
(async function createHooksForAggregateRoots () {
const { BaseUser } = models;
BaseUser.addHook('afterCreate', (m: any) => dispatchEventsCallback(m, 'base_user_id'));
BaseUser.addHook('afterDestroy', (m: any) => dispatchEventsCallback(m, 'base_user_id'));
BaseUser.addHook('afterUpdate', (m: any) => dispatchEventsCallback(m, 'base_user_id'));
BaseUser.addHook('afterSave', (m: any) => dispatchEventsCallback(m, 'base_user_id'));
BaseUser.addHook('afterUpsert', (m: any) => dispatchEventsCallback(m, 'base_user_id'));
})();
Hooks not running?: To ensure your Sequelize hooks always run, use the hooks: true option as described in "Ensuring Sequelize Hooks Always Get Run".
Hooking into succesful transactions with TypeORM
Using TypeORM, here's how we can utilize the entity listener decorators to accomplish the same thing.
@Entity()
export class User {
@AfterUpdate()
dispatchAggregateEvents() {
const aggregateId = new UniqueEntityID(this.userId);
DomainEvents.dispatchEventsForAggregate(aggregateId);
}
}
Conclusion
In this article, we learned:
- How domain logic that belongs to separate subdomains can get coupled
- How to create a basic domain events class
- How we can separate the process of notifying a subscriber to a domain event into 2 parts: creation and dispatch, and why it makes sense to do that.
- How to utilize the your ORM from the infrastructure layer to finalize the dispatch of handlers for your domain events
Want to see the code?: Check it out here.
Now go rule out there and rule the world.
Stay in touch!
Join 15000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Domain-Driven Design