Skip to content

Can we refactor events? #214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
mmmoli opened this issue Feb 12, 2025 · 3 comments
Open

Can we refactor events? #214

mmmoli opened this issue Feb 12, 2025 · 3 comments

Comments

@mmmoli
Copy link
Contributor

mmmoli commented Feb 12, 2025

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:

  1. Add the event in the domain layer (with custom args!)
  2. 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>
@4lessandrodev
Copy link
Owner

🙏 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 ProductCreatedEvent when 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 ProductCreated event 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:GenerateInvoice event 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!

@mmmoli
Copy link
Contributor Author

mmmoli commented Feb 12, 2025

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);
});

@4lessandrodev
Copy link
Owner

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:

  1. AWS EventBridge – Dispatches events that trigger another Lambda.
  2. AWS SQS – Enqueues events for later processing.
  3. 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. 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants