Can we refactor events?
You know how much I love this lib… ❤
From the Docs:
order.addEvent('OTHER_EVENT', (...args) => {
console.log(args);
});
// Or add an EventHandler instance
order.addEvent(new OrderCreatedEvent());
order.dispatchEvent('ORDER_HAS_BEGUN');
// dispatch with args
order.dispatchEvent('OTHER_EVENT', { info: 'custom_args' });
// OR call all added events
await order.dispatchAll();
My understanding
- Order is the Aggregate Root
- I want to add the event "OrderCreatedEvent" in a method within the domain
- Later, in a use-case, I might decide to dispatch the event
Complexity
Dispatching the event requires some infrastructure: My Event Bus. I don't want to add this in the domain layer – bad practice.
What I think I want, is:
- Add the event in the domain layer (with custom args!)
- Dispatch the event in the use case
Problem
The docs describe this round the opposite way, no?
Things I've tried
1. Custom Constructor to EventHandler
order.addEvent(new OrderItemAdded({ newItem }));
– Great, but I still can't dispatch without importing my Event Bus.
2. Custom Parameter
class Order extends AggregateRoot {
function addItem(eventHandler? EventHandler<Order>) {
...do work
if (eventHandler) this.addEvent(eventHandler)
}
}
- Great, but I cannot pass additional args of "newItem"
API I think we need
In domain layer
order.addEvent('ORDER_ITEM_ADDED', { newItem });
– I don't care how it's dispatched here. So I don't care about a "handler".
In UseCase
export class AddItemUseCase extends UseCase {
constructor(deps: {
orderItemAddedHandler: EventHandler<Order>
})
execute() {
...
order.addItem(...)
order.dispatch('ORDER_ITEM_ADDED', this.deps.orderItemAddedHandler)
}
}
– I'm not creating the handler here, I'm just passing it and saying "use this to dispatch the event, thanks!"
And finally, in infra:
export class OrderItemAddedHander extends EventHandler<Order> {
constructor(deps: {
eventBus: EventBus
})
dispatch(order: Order, args) {
this.deps.eventBus.dispatch({
custom: {
shape: {
order,
item: args.newItem
}
}
})
}
}
and
export const addOrderItemUseCase = new AddItemUseCase({
orderItemAddedHandler: new OrderItemAddedHander()
})
– All the definitions of things without any understanding or logic as to when this all gets called.
Bonus!
Strict typing of the event args would be 👌🏻
Something like:
interface CustomArgs {
newItem: …
}
EventHandler<Order, CustomArgs>
🙏 Thank you @mmmoli for your feedback and for appreciating my work!
I'm truly glad that you find value in the rich-domain library. Below, I’ll explain how the project available on GitHub demonstrates its intended use, and I'll also propose an improvement regarding dynamic event argument typing for better reliability.
📌 Example Project Using rich-domain Available on GitHub
The rich-domain library is designed to facilitate rich domain modeling following the principles of Domain-Driven Design (DDD). A complete implementation example is available in the official repository:
🔗 GitHub Repository:
👉 https://github.com/4lessandrodev/ddd-app/tree/main
This project demonstrates how to structure aggregates, repositories, domain events, and use cases while leveraging the recommended approach for using the library.
🛠️ How rich-domain is Designed to be Used?
The library was built with a strong emphasis on separating concerns between domain, application, and infrastructure. The ddd-app repository follows this pattern using the following concepts:
1️⃣ Domain Layer
This layer defines aggregates, value objects, and domain events, ensuring that business logic is properly encapsulated.
📌 Example: Domain Event
import { EventHandler } from "rich-domain";
import Product from "./product.aggregate";
export class ProductCreatedEvent extends EventHandler<Product> {
constructor() {
super({ eventName: 'ProductCreated' });
}
dispatch(aggregate: Product): void {
const model = aggregate.toObject();
const amount = model.price.value;
const itemName = model.name.value;
console.log(`EVENT DISPATCH: PRODUCT CREATED`);
console.log(model);
// Dispatch event to another context
aggregate.context().dispatchEvent('Invoice:GenerateInvoice', { itemName, amount });
}
}
🎯 Key points:
- Defines a domain event called
ProductCreatedEvent. - When a product is created, the event is not automatically dispatched, only registered within the aggregate.
- The event can be manually dispatched to notify other contexts.
📌 Example: Aggregate - Registering an Event
export class Product extends Aggregate<ProductProps> {
private constructor(props: ProductProps) {
super(props);
}
public static create(props: ProductProps): Result<Product> {
const product = new Product(props);
if (product.isNew()) product.addEvent(new ProductCreatedEvent());
return Ok(product);
}
}
🎯 Key points:
- Registers the
ProductCreatedEventwhen a new product is created. - The event is stored but not dispatched immediately.
2️⃣ Application Layer (Use Cases)
This layer contains use cases, which are responsible for orchestrating operations in the domain and determining when events should be dispatched.
📌 Example: Use Case
export class CreateProductUseCase implements IUseCase<CreateProductDto, Result<void>> {
constructor(private readonly repo: ProductRepositoryInterface) {}
async execute(dto: CreateProductDto): Promise<Result<void>> {
// Creating Value Objects
// ... props
// Create domain instance (Aggregate)
const product = Product.create(props).value();
// Saving to the repository
await this.repo.create(product);
return Ok();
}
}
🎯 Key points:
- Creates and validates the product's Value Objects.
- Calls the aggregate to create a new product.
- Saves the product in the repository, where events are later dispatched.
📌 Example: Repository - Dispatching an Event
async create(product: Product): Promise<void> {
this.db.push(product.toObject());
product.dispatchEvent('ProductCreated');
}
🎯 Key points:
- Saves the product to the database.
- Manually dispatches the
ProductCreatedevent after persistence.
3️⃣ Infrastructure Layer
This layer handles data persistence and event distribution across different contexts.
📌 Example: Event Subscription in Infrastructure
const context = Context.events();
// Infrastructure subscribes to domain events
context.subscribe('Invoice:GenerateInvoice', (args) => {
const [dto] = args.detail;
createInvoiceUseCase.execute(dto);
});
🎯 Key points:
- Allows other contexts to subscribe to events.
- When the
Invoice:GenerateInvoiceevent is dispatched, it is handled in the infrastructure layer.
📌 Improving Event Argument Typing
Currently, passing arguments to events does not enforce strict typing. A useful improvement would be to introduce dynamic event argument typing, ensuring better reliability.
📌 Proposed Improvement with Strong Typing
We can modify events to accept a generic type:
interface EventArgs {
[key: string]: unknown;
}
export class CustomEvent<T extends EventArgs> extends EventHandler<Product> {
constructor(eventName: string) {
super({ eventName });
}
dispatch(aggregate: Product, args: T): void {
console.log(`DISPATCHING EVENT: ${this.eventName}`);
console.log(args);
aggregate.context().dispatchEvent(this.eventName, args);
}
}
Now, when creating a specific event, we can enforce strong typing for its arguments:
interface ProductCreatedArgs {
itemName: string;
amount: number;
}
const productCreatedEvent = new CustomEvent<ProductCreatedArgs>('ProductCreated');
// Dispatching with strongly-typed arguments
productCreatedEvent.dispatch(product, { itemName: "Laptop", amount: 2000 });
✅ Benefits of This Approach
- Prevents type errors when passing arguments.
- Forces developers to provide only the expected data.
- Improves auto-completion and validation in TypeScript.
📌 Conclusion
The example project on GitHub showcases how rich-domain was designed to be used, properly separating concerns between:
- 📦 Domain → Defines aggregates, value objects, and events.
- 🚀 Application → Orchestrates use cases and decides when to dispatch events.
- 💾 Infrastructure → Handles persistence and event subscriptions between contexts.
The proposed improvement for dynamic event argument typing would add an extra layer of reliability and type safety, ensuring a more robust event-driven architecture.
🔗 GitHub Repository:
https://github.com/4lessandrodev/ddd-app/tree/main
🚀 With this approach, rich-domain becomes even more powerful for modeling scalable and well-structured domain-driven systems!
Makes total sense. Thank you.
But how does this work in a serverless setup?
context.subscribe('Invoice:GenerateInvoice', (args) => {
const [dto] = args.detail;
createInvoiceUseCase.execute(dto);
});
In a serverless setup, the context.subscribe approach won't work reliably because the execution environment terminates once the function returns a response. Any events queued in memory (like with EventEmitter) may never be processed.
✅ Recommended Approach
Instead of relying on in-memory event handling, use a persistent event-driven mechanism like:
- AWS EventBridge – Dispatches events that trigger another Lambda.
- AWS SQS – Enqueues events for later processing.
- Lambda Async Invocation – Calls another Lambda asynchronously.
How to Adapt?
Instead of context.subscribe, publish the event to EventBridge or an SQS queue:
import { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
const eventBridge = new EventBridgeClient({ region: "us-east-1" });
context.subscribe('Invoice:GenerateInvoice', async (args) => {
await eventBridge.send(new PutEventsCommand({
Entries: [{
Source: "myApp",
DetailType: "InvoiceGenerated",
Detail: JSON.stringify(args.detail),
EventBusName: "default"
}]
}));
});
This ensures the event persists beyond the Lambda execution lifecycle. 🚀