A social knowledge tool for researchers built on ATProto
1# Event System Architecture
2
3This document outlines the event system architecture for our DDD-based application, building on the existing `AggregateRoot` and `DomainEvents` abstractions.
4
5## Overview
6
7Our event system follows Domain-Driven Design principles where domain events represent meaningful business occurrences that domain experts care about. Events are raised by aggregate roots when their state changes in significant ways.
8
9## Core Abstractions (Already Implemented)
10
11### IDomainEvent Interface
12
13```typescript
14export interface IDomainEvent {
15 dateTimeOccurred: Date;
16 getAggregateId(): UniqueEntityID;
17}
18```
19
20### AggregateRoot Base Class
21
22- Maintains a list of domain events (`_domainEvents`)
23- Provides `addDomainEvent()` method to raise events
24- Automatically marks aggregate for dispatch via `DomainEvents.markAggregateForDispatch()`
25- Provides `clearEvents()` to clear events after dispatch
26
27### DomainEvents Static Class
28
29- Manages event handlers registration
30- Tracks aggregates that have raised events
31- Dispatches events when `dispatchEventsForAggregate()` is called
32- Provides handler registration via `register(callback, eventClassName)`
33
34## Event System Architecture Layers
35
36### 1. Domain Layer - Event Definitions
37
38**Location**: `src/modules/{module}/domain/events/`
39
40Domain events should be defined as classes that implement `IDomainEvent`. They represent pure business concepts.
41
42```typescript
43// Example: CardAddedToLibraryEvent
44export class CardAddedToLibraryEvent implements IDomainEvent {
45 public readonly dateTimeOccurred: Date;
46
47 constructor(
48 public readonly cardId: CardId,
49 public readonly curatorId: CuratorId,
50 public readonly cardType: CardTypeEnum,
51 public readonly url?: string,
52 ) {
53 this.dateTimeOccurred = new Date();
54 }
55
56 getAggregateId(): UniqueEntityID {
57 return this.cardId.getValue();
58 }
59}
60```
61
62**Event Naming Conventions**:
63
64- Use past tense: `CardAddedToLibraryEvent`, `CollectionCreatedEvent`
65- Be specific about the business action: `UrlCardAddedToLibraryEvent` vs generic `CardUpdatedEvent`
66- Include relevant context data needed by event handlers
67
68### 2. Domain Layer - Raising Events in Aggregates
69
70Events should be raised within aggregate methods when business-significant state changes occur:
71
72```typescript
73// In Card aggregate
74export class Card extends AggregateRoot<CardProps> {
75 public addToLibrary(curatorId: CuratorId): Result<void> {
76 // Business logic validation
77 if (this.isInLibrary(curatorId)) {
78 return err(new Error('Card already in library'));
79 }
80
81 // Apply state change
82 this.props.libraries.push(curatorId);
83
84 // Raise domain event
85 this.addDomainEvent(
86 new CardAddedToLibraryEvent(
87 this.cardId,
88 curatorId,
89 this.props.type.value,
90 this.props.content.url?.value,
91 ),
92 );
93
94 return ok();
95 }
96}
97```
98
99**When to Raise Events**:
100
101- After successful state changes, not before
102- For business-significant actions that other bounded contexts care about
103- When side effects or integrations need to be triggered
104- For audit trails and business intelligence
105
106### 3. Application Layer - Event Handlers
107
108**Location**: `src/modules/{module}/application/eventHandlers/`
109
110Event handlers contain application logic that should happen in response to domain events. They coordinate between different services and bounded contexts.
111
112```typescript
113// Example: CardAddedToLibraryEventHandler
114export class CardAddedToLibraryEventHandler {
115 constructor(
116 private profileService: IProfileService,
117 private notificationService: INotificationService,
118 private analyticsService: IAnalyticsService,
119 ) {}
120
121 async handle(event: CardAddedToLibraryEvent): Promise<void> {
122 try {
123 // Update user activity metrics
124 await this.analyticsService.trackCardAddedToLibrary({
125 cardId: event.cardId.getStringValue(),
126 curatorId: event.curatorId.value,
127 cardType: event.cardType,
128 timestamp: event.dateTimeOccurred,
129 });
130
131 // Send notification to followers (if public library)
132 const profile = await this.profileService.getProfile(
133 event.curatorId.value,
134 );
135 if (profile.isSuccess() && profile.value.isPublic) {
136 await this.notificationService.notifyFollowers({
137 userId: event.curatorId.value,
138 action: 'added_card_to_library',
139 cardId: event.cardId.getStringValue(),
140 });
141 }
142
143 // Trigger content indexing for search
144 if (event.url) {
145 await this.searchIndexService.indexCard({
146 cardId: event.cardId.getStringValue(),
147 url: event.url,
148 curatorId: event.curatorId.value,
149 });
150 }
151 } catch (error) {
152 // Log error but don't fail the main operation
153 console.error('Error handling CardAddedToLibraryEvent:', error);
154 }
155 }
156}
157```
158
159**Event Handler Principles**:
160
161- Handlers should be idempotent (safe to run multiple times)
162- Failures in handlers should not fail the main business operation
163- Handlers can coordinate multiple services but shouldn't contain business logic
164- Use dependency injection for testability
165
166### 4. Infrastructure Layer - Event Handler Registration
167
168**Location**: `src/shared/infrastructure/events/`
169
170Create an event handler registry that wires up domain events to their handlers:
171
172```typescript
173// EventHandlerRegistry
174export class EventHandlerRegistry {
175 constructor(
176 private cardAddedToLibraryHandler: CardAddedToLibraryEventHandler,
177 private collectionCreatedHandler: CollectionCreatedEventHandler,
178 // ... other handlers
179 ) {}
180
181 registerAllHandlers(): void {
182 // Register card events
183 DomainEvents.register(
184 (event: CardAddedToLibraryEvent) =>
185 this.cardAddedToLibraryHandler.handle(event),
186 CardAddedToLibraryEvent.name,
187 );
188
189 DomainEvents.register(
190 (event: CollectionCreatedEvent) =>
191 this.collectionCreatedHandler.handle(event),
192 CollectionCreatedEvent.name,
193 );
194
195 // ... register other event handlers
196 }
197
198 clearAllHandlers(): void {
199 DomainEvents.clearHandlers();
200 }
201}
202```
203
204### 5. Infrastructure Layer - Event Dispatch Integration
205
206Events should be dispatched at the end of successful use case executions, typically in a repository save operation or use case completion:
207
208```typescript
209// In repository implementation
210export class DrizzleCardRepository implements ICardRepository {
211 async save(card: Card): Promise<Result<void>> {
212 try {
213 // Save to database
214 await this.db.insert(cards).values(this.toPersistence(card));
215
216 // Dispatch events after successful save
217 DomainEvents.dispatchEventsForAggregate(card.id);
218
219 return ok();
220 } catch (error) {
221 return err(error);
222 }
223 }
224}
225```
226
227**Alternative**: Dispatch in use case after repository operations:
228
229```typescript
230// In use case
231export class AddUrlToLibraryUseCase {
232 async execute(
233 request: AddUrlToLibraryDTO,
234 ): Promise<Result<AddUrlToLibraryResponseDTO>> {
235 try {
236 // ... business logic
237
238 const saveResult = await this.cardRepository.save(urlCard);
239 if (saveResult.isErr()) {
240 return err(saveResult.error);
241 }
242
243 // Dispatch events after successful persistence
244 DomainEvents.dispatchEventsForAggregate(urlCard.id);
245
246 return ok(response);
247 } catch (error) {
248 return err(AppError.UnexpectedError.create(error));
249 }
250 }
251}
252```
253
254## Event System Integration Points
255
256### Factory Registration
257
258Event handlers need to be registered in the factory system:
259
260```typescript
261// In ServiceFactory
262export class ServiceFactory {
263 static create(
264 configService: EnvironmentConfigService,
265 repositories: Repositories,
266 ): Services {
267 // ... existing services
268
269 // Event handlers
270 const cardAddedToLibraryHandler = new CardAddedToLibraryEventHandler(
271 profileService,
272 notificationService,
273 analyticsService,
274 );
275
276 const eventHandlerRegistry = new EventHandlerRegistry(
277 cardAddedToLibraryHandler,
278 // ... other handlers
279 );
280
281 // Register all event handlers
282 eventHandlerRegistry.registerAllHandlers();
283
284 return {
285 // ... existing services
286 eventHandlerRegistry,
287 };
288 }
289}
290```
291
292### Testing Considerations
293
294For testing, you'll want to:
295
2961. **Clear events between tests**:
297
298```typescript
299afterEach(() => {
300 DomainEvents.clearHandlers();
301 DomainEvents.clearMarkedAggregates();
302});
303```
304
3052. **Test event raising**:
306
307```typescript
308it('should raise CardAddedToLibraryEvent when card is added to library', () => {
309 const card = CardFactory.create(cardInput);
310 const curatorId = CuratorId.create('did:plc:test');
311
312 card.addToLibrary(curatorId);
313
314 expect(card.domainEvents).toHaveLength(1);
315 expect(card.domainEvents[0]).toBeInstanceOf(CardAddedToLibraryEvent);
316});
317```
318
3193. **Test event handlers in isolation**:
320
321```typescript
322it('should track analytics when card is added to library', async () => {
323 const handler = new CardAddedToLibraryEventHandler(
324 mockProfileService,
325 mockNotificationService,
326 mockAnalyticsService,
327 );
328
329 const event = new CardAddedToLibraryEvent(
330 cardId,
331 curatorId,
332 CardTypeEnum.URL,
333 'https://example.com',
334 );
335
336 await handler.handle(event);
337
338 expect(mockAnalyticsService.trackCardAddedToLibrary).toHaveBeenCalledWith({
339 cardId: cardId.getStringValue(),
340 curatorId: curatorId.value,
341 cardType: CardTypeEnum.URL,
342 timestamp: event.dateTimeOccurred,
343 });
344});
345```
346
347## Event Design Guidelines
348
349### Event Granularity
350
351- **Too Fine**: `CardTitleChangedEvent`, `CardDescriptionChangedEvent` (unless specifically needed)
352- **Too Coarse**: `CardUpdatedEvent` (not specific enough)
353- **Just Right**: `CardAddedToLibraryEvent`, `CardRemovedFromCollectionEvent`
354
355### Event Data
356
357- Include the aggregate ID that raised the event
358- Include enough context for handlers to do their work
359- Don't include entire aggregate state (events should be lightweight)
360- Include timestamp for ordering and auditing
361
362### Event Naming
363
364- Use past tense (events represent things that have happened)
365- Be specific about the business action
366- Include the aggregate type: `Card...Event`, `Collection...Event`
367
368### Handler Design
369
370- Keep handlers focused on a single responsibility
371- Make handlers idempotent
372- Don't let handler failures break the main business operation
373- Use async/await for I/O operations
374- Log errors appropriately
375
376## Example Event Scenarios
377
378### URL Card Added to Library
379
3801. **Trigger**: `Card.addToLibrary()` method called
3812. **Event**: `CardAddedToLibraryEvent` raised
3823. **Handlers**:
383 - Update user activity metrics
384 - Notify followers
385 - Index for search
386 - Update recommendation engine
387
388### Collection Created
389
3901. **Trigger**: `Collection.create()` factory method
3912. **Event**: `CollectionCreatedEvent` raised
3923. **Handlers**:
393 - Send welcome notification
394 - Update user profile stats
395 - Trigger initial recommendations
396
397### Card Removed from Library
398
3991. **Trigger**: `Card.removeFromLibrary()` method
4002. **Event**: `CardRemovedFromLibraryEvent` raised
4013. **Handlers**:
402 - Update analytics
403 - Remove from search index
404 - Clean up recommendations
405
406## useful context
407
408```
409docs/event-system-architecture.md
410docs/features/GUIDE.md
411src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
412src/modules/cards/domain/Card.ts
413src/modules/cards/domain/events/CardAddedToLibraryEvent.ts
414src/modules/cards/infrastructure/http/controllers/AddUrlToLibraryController.ts
415src/modules/feeds/application/eventHandlers/CardAddedToLibraryEventHandler.ts
416src/modules/feeds/application/ports/IFeedService.ts
417src/modules/notifications/application/eventHandlers/CardAddedToLibraryEventHandler.ts
418src/modules/notifications/application/ports/INotificationService.ts
419src/shared/domain/AggregateRoot.ts
420src/shared/domain/events/DomainEvents.ts
421src/shared/domain/events/IDomainEvent.ts
422src/shared/infrastructure/events/EventHandlerRegistry.ts
423```