Where Do Domain Events Get Created? | Domain Driven Design w/ TypeScript

Last time, we talked about how to create a facade over our ORM using the Repository pattern. Today, we're talking a little bit about where we create Domain Events.
Domain Events are a huge part of Domain-Driven Design.
When implemented correctly, they allow us to chain domain logic, maintain single responsibility, and cross architectural boundaries (subdomains) using the Observer pattern (which is a well-known way to separate concerns and decouple async logic).
The question is: "where do Domain Events get created"?
Domain Events are a part of the domain layer, so they belong with all the other domain layer constructs like entities, value objects and domain services.
OK cool, but where exactly do they get created?
Right on the aggregate root.
Allow me to explain.
To me, the most beautiful thing about Domain-Driven Design is that it urges you towards creating your own domain-specific language (DSL).
You expose only the methods and attributes that make sense to the domain. And those methods and attributes only belong on entities and value objects that it makes sense to belong on.
class User {
// factory method to create users, yep
public static create (props): User { }
// yup, also a valid domain operation
public deactivate (): void {}
// ❌ No. A client shouldn't need to use this operation.
// Use a Value Object to encapsulate validation logic instead:
// https://khalilstemmler.com/articles/typescript-value-object/
public validateFirstName (firstName): boolean {}
}
Determining what makes sense is hard. It usually takes a lot of conversation with domain experts and multiple iterations of the model.
This increased importance on YAGNI, and only exposing operations that are valid to the model are also a big reason why getters and setters get a lot of usage for us in Domain-Driven Design.
Using getters and setters, not only do we get to specify exactly what is allowed to be accessed/changed, but we also get to specify when it's allowed to be accessed/changed, and what happens when it's accessed/changed.
When essential properties are accessed or changed, it might make sense to create a Domain Event.
For example, if we were working on White Label's feature that enables Traders
to accept or decline Offers
, we'd have to walk through the process of determining where Domain Logic should belong.
Following that process, we'd discover that acceptOffer()
and declineOffer()
perform mutations to the Offer
aggregate root itself.
Those operations can be done without the involvement of any other domain entities, so it makes sense to locate them directly on the Offer
aggregate root.
export type OfferState = 'initial' | 'accepted' | 'declined'
interface OfferProps {
...
state: OfferState;
}
export class Offer extends AggregateRoot<OfferProps> {
...
get offerId (): OfferId {
return OfferId.create(this._id);
}
get offerState (): OfferState {
return this.props.state;
}
public acceptOffer (): Result<any> {
switch (this.offerState) {
case 'initial':
// Notice how there is not a public setter for the
// 'state' attribute. That's because it's important that
// we intercept changes to state so that we can create and add
// a Domain Event to the "observable subject" when it's
// appropriate to do so.
this.props.state = 'accepted';
// And then we create the domain event.
this.addDomainEvent(new OfferAcceptedEvent(this.offerId));
return Result.ok<any>();
case 'accepted':
return Result.fail<any>(new Error('Already accepted this offer'));
case 'declined':
return Result.fail<any>(new Error("Can't accept an offer already declined"));
default:
return Result.fail<any>(new Error("Offer was in an invalid state"));
}
}
public declineOffer (): Result<any> {
switch (this.offerState) {
case 'initial':
// Same deal is going on here.
this.props.state = 'declined';
this.addDomainEvent(new OfferDeclinedEvent(this.offerId));
return Result.ok<any>();
case 'accepted':
return Result.fail<any>(new Error('Already accepted this offer'));
case 'declined':
return Result.fail<any>(new Error("Can't decline an offer already declined"));
default:
return Result.fail<any>(new Error("Offer was in an invalid state"));
}
}
private constructor (props: OfferProps, id?: UniqueEntityId) {
super(props, id);
}
}
And if we were taking the use case approach of hooking this up, executing this feature would look like:
export class AcceptOfferUseCase implements UseCase<AcceptOfferDTO, Result<Offer>> {
private offerRepo: IOfferRepo;
private artistRepo: IArtistRepo;
constructor (offerRepo: IOfferRepo) {
this.offerRepo = offerRepo
}
public async execute (request: AcceptOfferDTO): Promise<Result<Offer>> {
const { offerId } = request;
const offer = this.offerRepo.findById(offerId);
if (!!offer === false) {
return Result.fail<Offer>(ErrorType.NOT_FOUND)
}
// Creates the domain event
offer.acceptOffer();
// Persists the offer and dispatches all created domain events
this.offerRepo.save(offer);
return Result.ok<Offer>(offer)
}
}
Because Domain Events are part of the domain, we'll always want to try to place Domain Events as close to the entities/aggregate roots as possible.
If we can trust that the domain layer will always generate the appropriate domain events in those key scenarios, we can enable several applications (perhaps deployed in separate bounded contexts and propagated over the network using a message queue like RabbitMQ) to implement their own varying application layer logic to handle these events however they please.
For example, if our Billing
subdomain were to subscribe to the OfferAcceptedEvent
, we might want to fulfill the trade by facilitating the trade between the two parties and charging a 0.2% processing fee.
Discussion
Liked this? Sing it loud and proud 👨🎤.
Stay in touch!
Enjoying so far? Join 15000+ Software Essentialists getting my posts delivered straight to your inbox each week. I won't spam ya. 🖖
View more in Domain-Driven Design
You may also enjoy...
A few more related articles




Want to be notified when new content comes out?
Join 15000+ other Software Essentialists learning how to master The Essentials of software design and architecture.
7 Comments
Commenting has been disabled for now. To ask questions and discuss this post, join the community.
Thanks for this great sharing. If we publish domain event before the change has been persisted, there is chance that the persistence might fail. The chance is much higher in distributed system where conflict could happen more often. We will end up with in the situation that domain event is dispatched but actually the event doesn't actually happen. I think this introduces unacceptable inconsistency to business logic domain. What is your thoughts on this?
Looking forward to your response on supernavy’s comment
@khalil: another great article!
@supernavy: would also like to hear from Khalil. But here is my take --
If the domain event mechanism is in memory and all your subdomains are part of the same deployable (ie a monolith on the same server) I believe you could simply wait for the repository to succeed in persisting the aggregate, then publish the domain events. If the repo gives an error don't publish the events.
However if the subdomains are split into separate deployables (aka microservices) there's another problem as well. Even if you wait until the aggregate is persisted, publishing the domain event to a message queue is usually also an async operation across the network. So what happens if the write to the DB succeeds but the message gets dropped? Would you roll back the DB changes or retry publishing the message? That could also fail... This is why distributed systems are complicated and now we are starting to get into CAP theorem: https://en.wikipedia.org/wiki/CAP_theorem .
Here are 2 potential options. Disclaimer though, I've not worked on a system like this before so take it with a large grain of salt...
Thanks for this great artcile, In the context of a REST API, how would you handle the http response, when the responsability is delegated to other domains ?
Let's say for example that my API must create a user and then process other things, then send a pdf file. So the createUser controller will call the createUser useCase then will create a new user, and fire an event. Then the pdf module subscribed to that event will generate a new pdf and continue the process. But what if the PDF module (which could be in another microservice) fails to create the pdf , how would you recreate the context to send an http 500 server error to the createUser route ?
This is great. I love the fact that I could appreciate doing all this as your use case paints a picture where this isn't all noise but actually a valid business process.
Lovely stuff.
This feels at home haha "Determining what makes sense is hard. It usually takes a lot of conversation with domain experts and multiple iterations of the model."
At least taking it into account to include just what makes sense is the most important step, you may get i wrong sometimes but in multiple iterations should get fixed, hopefully.
@supernavy @farren
It is possible domain events can be transactionally written alongside the aggregate in a domain events table. This technique (seemingly implied in the article!) is sometimes referred to as a "transactional outbox". If domain events need to be pushed into another message broker, a process polls the outbox to push them there.
It's true that *this* new process could fail, but since the domain events are stored, we would simply retry that process. The risk of submitting that event more than once defines this mechanism as "at least once".
As a further improvement, if the message broker provided a mechanism to avoid publishing duplicate messages, like using the message id for idempotency, we could have an "exactly once" messaging mechanism.