A social knowledge tool for researchers built on ATProto

include claude code guide

+664
+664
.claude/guides/use-case-implementation-patterns.md
··· 1 + # Use Case Implementation Patterns 2 + 3 + This guide documents the patterns and best practices for implementing use cases in the Semble application, with full vertical stack integration. 4 + 5 + ## Table of Contents 6 + 1. [Use Case Structure](#use-case-structure) 7 + 2. [Repository Patterns](#repository-patterns) 8 + 3. [Upsert Behavior Pattern](#upsert-behavior-pattern) 9 + 4. [Explicit Control Pattern](#explicit-control-pattern) 10 + 5. [Testing Patterns](#testing-patterns) 11 + 6. [Full Stack Integration](#full-stack-integration) 12 + 13 + ## Use Case Structure 14 + 15 + ### Base Template 16 + 17 + ```typescript 18 + import { Result, ok, err } from '../../../../../shared/core/Result'; 19 + import { BaseUseCase } from '../../../../../shared/core/UseCase'; 20 + import { UseCaseError } from '../../../../../shared/core/UseCaseError'; 21 + import { AppError } from '../../../../../shared/core/AppError'; 22 + import { IEventPublisher } from '../../../../../shared/application/events/IEventPublisher'; 23 + 24 + export interface YourUseCaseDTO { 25 + // Input parameters 26 + field: string; 27 + curatorId: string; 28 + } 29 + 30 + export interface YourUseCaseResponseDTO { 31 + // Response fields 32 + id: string; 33 + } 34 + 35 + export class ValidationError extends UseCaseError { 36 + constructor(message: string) { 37 + super(message); 38 + } 39 + } 40 + 41 + export class YourUseCase extends BaseUseCase< 42 + YourUseCaseDTO, 43 + Result<YourUseCaseResponseDTO, ValidationError | AppError.UnexpectedError> 44 + > { 45 + constructor( 46 + private repository: IRepository, 47 + private domainService: DomainService, 48 + eventPublisher: IEventPublisher, 49 + ) { 50 + super(eventPublisher); 51 + } 52 + 53 + async execute( 54 + request: YourUseCaseDTO, 55 + ): Promise< 56 + Result<YourUseCaseResponseDTO, ValidationError | AppError.UnexpectedError> 57 + > { 58 + try { 59 + // 1. Validate input and create value objects 60 + const valueObjectResult = ValueObject.create(request.field); 61 + if (valueObjectResult.isErr()) { 62 + return err(new ValidationError(valueObjectResult.error.message)); 63 + } 64 + 65 + // 2. Check existence/preconditions 66 + const existingEntity = await this.repository.findByX(...); 67 + if (existingEntity.isErr()) { 68 + return err(AppError.UnexpectedError.create(existingEntity.error)); 69 + } 70 + 71 + // 3. Execute business logic 72 + // ... 73 + 74 + // 4. Persist changes 75 + const saveResult = await this.repository.save(entity); 76 + if (saveResult.isErr()) { 77 + return err(AppError.UnexpectedError.create(saveResult.error)); 78 + } 79 + 80 + // 5. Publish events 81 + const publishResult = await this.publishEventsForAggregate(entity); 82 + if (publishResult.isErr()) { 83 + console.error('Failed to publish events:', publishResult.error); 84 + // Don't fail the operation if event publishing fails 85 + } 86 + 87 + // 6. Return success 88 + return ok({ id: entity.id.getStringValue() }); 89 + } catch (error) { 90 + return err(AppError.UnexpectedError.create(error)); 91 + } 92 + } 93 + } 94 + ``` 95 + 96 + ## Repository Patterns 97 + 98 + ### Finding Related Entities 99 + 100 + When you need to find related entities (e.g., note cards for a URL card), add specific repository methods: 101 + 102 + #### Interface Definition 103 + ```typescript 104 + // src/modules/cards/domain/ICardRepository.ts 105 + export interface ICardRepository { 106 + findById(id: CardId): Promise<Result<Card | null>>; 107 + findUsersUrlCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 108 + findUsersNoteCardByUrl(url: URL, curatorId: CuratorId): Promise<Result<Card | null>>; 109 + save(card: Card): Promise<Result<void>>; 110 + delete(cardId: CardId): Promise<Result<void>>; 111 + } 112 + ``` 113 + 114 + #### In-Memory Implementation (for tests) 115 + ```typescript 116 + async findUsersNoteCardByUrl( 117 + url: URL, 118 + curatorId: CuratorId, 119 + ): Promise<Result<Card | null>> { 120 + try { 121 + const card = Array.from(this.cards.values()).find( 122 + (card) => 123 + card.type.value === 'NOTE' && 124 + card.url?.value === url.value && 125 + card.props.curatorId.equals(curatorId), 126 + ); 127 + return ok(card ? this.clone(card) : null); 128 + } catch (error) { 129 + return err(error as Error); 130 + } 131 + } 132 + ``` 133 + 134 + #### Drizzle Implementation 135 + ```typescript 136 + async findUsersNoteCardByUrl( 137 + url: URL, 138 + curatorId: CuratorId, 139 + ): Promise<Result<Card | null>> { 140 + try { 141 + const urlValue = url.value; 142 + 143 + const cardResult = await this.db 144 + .select() 145 + .from(cards) 146 + .where( 147 + and( 148 + eq(cards.url, urlValue), 149 + eq(cards.type, 'NOTE'), 150 + eq(cards.authorId, curatorId.value), 151 + ), 152 + ) 153 + .limit(1); 154 + 155 + if (cardResult.length === 0) { 156 + return ok(null); 157 + } 158 + 159 + // ... map to domain entity 160 + } catch (error) { 161 + return err(error as Error); 162 + } 163 + } 164 + ``` 165 + 166 + ## Upsert Behavior Pattern 167 + 168 + Use this pattern when you want **additive** behavior - only add, never remove. 169 + 170 + ### Example: AddUrlToLibraryUseCase 171 + 172 + This use case demonstrates modified upsert behavior: 173 + - If URL card doesn't exist: Create it 174 + - If URL card exists: Reuse it 175 + - If note is provided and note card exists: **Update** the note 176 + - If note is provided and note card doesn't exist: **Create** the note 177 + - If collections are provided: **Only add** to collections (never remove) 178 + 179 + ```typescript 180 + // Check if note card already exists 181 + const existingNoteCardResult = 182 + await this.cardRepository.findUsersNoteCardByUrl(url, curatorId); 183 + if (existingNoteCardResult.isErr()) { 184 + return err(AppError.UnexpectedError.create(existingNoteCardResult.error)); 185 + } 186 + 187 + noteCard = existingNoteCardResult.value; 188 + 189 + if (noteCard) { 190 + // Update existing note card 191 + const newContentResult = CardContent.createNoteContent(request.note); 192 + if (newContentResult.isErr()) { 193 + return err(new ValidationError(newContentResult.error.message)); 194 + } 195 + 196 + const updateContentResult = noteCard.updateContent(newContentResult.value); 197 + if (updateContentResult.isErr()) { 198 + return err(new ValidationError(updateContentResult.error.message)); 199 + } 200 + 201 + const saveNoteCardResult = await this.cardRepository.save(noteCard); 202 + if (saveNoteCardResult.isErr()) { 203 + return err(AppError.UnexpectedError.create(saveNoteCardResult.error)); 204 + } 205 + } else { 206 + // Create new note card 207 + // ... 208 + } 209 + 210 + // Collections are ONLY added (via addCardToCollections service) 211 + // This service adds to collections but doesn't remove from existing ones 212 + if (request.collectionIds && request.collectionIds.length > 0) { 213 + const addToCollectionsResult = 214 + await this.cardCollectionService.addCardToCollections( 215 + urlCard, 216 + collectionIds, 217 + curatorId, 218 + ); 219 + // ... 220 + } 221 + ``` 222 + 223 + ### Testing Upsert Behavior 224 + 225 + ```typescript 226 + it('should update existing note card when URL already exists with a note', async () => { 227 + const url = 'https://example.com/existing'; 228 + 229 + // First request creates URL card with note 230 + const firstRequest = { url, note: 'Original note', curatorId: curatorId.value }; 231 + const firstResult = await useCase.execute(firstRequest); 232 + expect(firstResult.isOk()).toBe(true); 233 + const firstResponse = firstResult.unwrap(); 234 + 235 + // Second request updates the note 236 + const secondRequest = { url, note: 'Updated note', curatorId: curatorId.value }; 237 + const secondResult = await useCase.execute(secondRequest); 238 + expect(secondResult.isOk()).toBe(true); 239 + const secondResponse = secondResult.unwrap(); 240 + 241 + // Should have same note card ID 242 + expect(secondResponse.noteCardId).toBe(firstResponse.noteCardId); 243 + 244 + // Verify note was updated 245 + const savedCards = cardRepository.getAllCards(); 246 + const updatedNoteCard = savedCards.find( 247 + (card) => card.content.type === CardTypeEnum.NOTE, 248 + ); 249 + expect(updatedNoteCard?.content.noteContent?.text).toBe('Updated note'); 250 + }); 251 + ``` 252 + 253 + ## Explicit Control Pattern 254 + 255 + Use this pattern when you need **precise control** over what gets added and removed. 256 + 257 + ### Example: UpdateUrlCardAssociationsUseCase 258 + 259 + This use case demonstrates explicit control: 260 + - Requires URL card to already exist 261 + - Provides separate controls for adding vs removing collections 262 + - Can create or update notes 263 + - Returns detailed information about what changed 264 + 265 + ```typescript 266 + export interface UpdateUrlCardAssociationsDTO { 267 + url: string; 268 + curatorId: string; 269 + note?: string; 270 + addToCollections?: string[]; // Explicit add 271 + removeFromCollections?: string[]; // Explicit remove 272 + } 273 + 274 + export interface UpdateUrlCardAssociationsResponseDTO { 275 + urlCardId: string; 276 + noteCardId?: string; 277 + addedToCollections: string[]; // What was actually added 278 + removedFromCollections: string[]; // What was actually removed 279 + } 280 + 281 + // In execute(): 282 + // 1. Require URL card to exist 283 + const urlCard = existingUrlCardResult.value; 284 + if (!urlCard) { 285 + return err( 286 + new ValidationError( 287 + 'URL card not found. Please add the URL to your library first.', 288 + ), 289 + ); 290 + } 291 + 292 + // 2. Handle adding to collections 293 + if (request.addToCollections && request.addToCollections.length > 0) { 294 + const addToCollectionsResult = 295 + await this.cardCollectionService.addCardToCollections( 296 + urlCard, 297 + collectionIds, 298 + curatorId, 299 + ); 300 + // Track what was added 301 + for (const collection of addToCollectionsResult.value) { 302 + addedToCollections.push(collection.collectionId.getStringValue()); 303 + } 304 + } 305 + 306 + // 3. Handle removing from collections 307 + if (request.removeFromCollections && request.removeFromCollections.length > 0) { 308 + const removeFromCollectionsResult = 309 + await this.cardCollectionService.removeCardFromCollections( 310 + urlCard, 311 + collectionIds, 312 + curatorId, 313 + ); 314 + // Track what was removed 315 + for (const collection of removeFromCollectionsResult.value) { 316 + removedFromCollections.push(collection.collectionId.getStringValue()); 317 + } 318 + } 319 + 320 + return ok({ 321 + urlCardId: urlCard.cardId.getStringValue(), 322 + noteCardId: noteCard?.cardId.getStringValue(), 323 + addedToCollections, 324 + removedFromCollections, 325 + }); 326 + ``` 327 + 328 + ### Testing Explicit Control 329 + 330 + ```typescript 331 + it('should add and remove from different collections in same request', async () => { 332 + // Setup: URL in collection1 333 + await addUrlToLibraryUseCase.execute({ 334 + url, 335 + collectionIds: [collection1.collectionId.getStringValue()], 336 + curatorId: curatorId.value, 337 + }); 338 + 339 + // Execute: Add to collection2 and collection3, remove from collection1 340 + const result = await useCase.execute({ 341 + url, 342 + curatorId: curatorId.value, 343 + addToCollections: [ 344 + collection2.collectionId.getStringValue(), 345 + collection3.collectionId.getStringValue(), 346 + ], 347 + removeFromCollections: [collection1.collectionId.getStringValue()], 348 + }); 349 + 350 + // Verify 351 + expect(result.isOk()).toBe(true); 352 + const response = result.unwrap(); 353 + expect(response.addedToCollections).toHaveLength(2); 354 + expect(response.removedFromCollections).toHaveLength(1); 355 + expect(response.addedToCollections).toContain(collection2.collectionId.getStringValue()); 356 + expect(response.removedFromCollections).toContain(collection1.collectionId.getStringValue()); 357 + }); 358 + ``` 359 + 360 + ## Testing Patterns 361 + 362 + ### Test File Structure 363 + 364 + ```typescript 365 + describe('YourUseCase', () => { 366 + let useCase: YourUseCase; 367 + let repository: InMemoryRepository; 368 + let domainService: DomainService; 369 + let eventPublisher: FakeEventPublisher; 370 + 371 + beforeEach(() => { 372 + repository = new InMemoryRepository(); 373 + eventPublisher = new FakeEventPublisher(); 374 + domainService = new DomainService(repository, eventPublisher); 375 + 376 + useCase = new YourUseCase( 377 + repository, 378 + domainService, 379 + eventPublisher, 380 + ); 381 + }); 382 + 383 + afterEach(() => { 384 + repository.clear(); 385 + eventPublisher.clear(); 386 + }); 387 + 388 + describe('Feature group 1', () => { 389 + it('should do X when Y', async () => { 390 + // Arrange 391 + const request = { /* ... */ }; 392 + 393 + // Act 394 + const result = await useCase.execute(request); 395 + 396 + // Assert 397 + expect(result.isOk()).toBe(true); 398 + // ... more assertions 399 + }); 400 + }); 401 + 402 + describe('Validation', () => { 403 + it('should fail with invalid input', async () => { 404 + const result = await useCase.execute({ /* invalid */ }); 405 + 406 + expect(result.isErr()).toBe(true); 407 + if (result.isErr()) { 408 + expect(result.error.message).toContain('expected error'); 409 + } 410 + }); 411 + }); 412 + }); 413 + ``` 414 + 415 + ### Test Coverage Checklist 416 + 417 + For each use case, ensure tests cover: 418 + - ✅ Happy path (basic functionality) 419 + - ✅ Update existing entities 420 + - ✅ Create new entities 421 + - ✅ Edge cases (empty inputs, null values) 422 + - ✅ Validation errors (invalid IDs, invalid URLs, etc.) 423 + - ✅ Not found scenarios 424 + - ✅ Permission/authorization scenarios 425 + - ✅ Multiple operations in single request 426 + - ✅ Event publishing verification 427 + 428 + ## Full Stack Integration 429 + 430 + ### 1. Use Case Factory 431 + 432 + Add to `src/shared/infrastructure/http/factories/UseCaseFactory.ts`: 433 + 434 + ```typescript 435 + // 1. Import 436 + import { YourUseCase } from '../../../../modules/cards/application/useCases/commands/YourUseCase'; 437 + 438 + // 2. Add to interface 439 + export interface UseCases { 440 + // ... 441 + yourUseCase: YourUseCase; 442 + } 443 + 444 + // 3. Instantiate in createForWebApp 445 + return { 446 + // ... 447 + yourUseCase: new YourUseCase( 448 + repositories.cardRepository, 449 + services.domainService, 450 + services.eventPublisher, 451 + ), 452 + }; 453 + ``` 454 + 455 + ### 2. Controller 456 + 457 + Create `src/modules/cards/infrastructure/http/controllers/YourController.ts`: 458 + 459 + ```typescript 460 + import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 461 + import { Response } from 'express'; 462 + import { YourUseCase } from '../../../application/useCases/commands/YourUseCase'; 463 + import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 464 + 465 + export class YourController extends Controller { 466 + constructor(private yourUseCase: YourUseCase) { 467 + super(); 468 + } 469 + 470 + async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 471 + try { 472 + const { field1, field2 } = req.body; 473 + const curatorId = req.did; 474 + 475 + if (!curatorId) { 476 + return this.unauthorized(res); 477 + } 478 + 479 + if (!field1) { 480 + return this.badRequest(res, 'Field1 is required'); 481 + } 482 + 483 + const result = await this.yourUseCase.execute({ 484 + field1, 485 + field2, 486 + curatorId, 487 + }); 488 + 489 + if (result.isErr()) { 490 + return this.fail(res, result.error); 491 + } 492 + 493 + return this.ok(res, result.value); 494 + } catch (error: any) { 495 + return this.fail(res, error); 496 + } 497 + } 498 + } 499 + ``` 500 + 501 + ### 3. Controller Factory 502 + 503 + Add to `src/shared/infrastructure/http/factories/ControllerFactory.ts`: 504 + 505 + ```typescript 506 + // 1. Import 507 + import { YourController } from '../../../../modules/cards/infrastructure/http/controllers/YourController'; 508 + 509 + // 2. Add to interface 510 + export interface Controllers { 511 + // ... 512 + yourController: YourController; 513 + } 514 + 515 + // 3. Instantiate 516 + return { 517 + // ... 518 + yourController: new YourController(useCases.yourUseCase), 519 + }; 520 + ``` 521 + 522 + ### 4. Routes 523 + 524 + Add to `src/modules/cards/infrastructure/http/routes/cardRoutes.ts`: 525 + 526 + ```typescript 527 + // 1. Import controller type 528 + import { YourController } from '../controllers/YourController'; 529 + 530 + // 2. Add parameter to createCardRoutes 531 + export function createCardRoutes( 532 + authMiddleware: AuthMiddleware, 533 + // ... 534 + yourController: YourController, 535 + ): Router { 536 + // ... 537 + 538 + // 3. Add route 539 + router.put( 540 + '/your-endpoint', 541 + authMiddleware.ensureAuthenticated(), 542 + (req, res) => yourController.execute(req, res), 543 + ); 544 + 545 + return router; 546 + } 547 + ``` 548 + 549 + ### 5. Wire through module routes 550 + 551 + Update `src/modules/cards/infrastructure/http/routes/index.ts`: 552 + 553 + ```typescript 554 + // 1. Import 555 + import { YourController } from '../controllers/YourController'; 556 + 557 + // 2. Add parameter 558 + export function createCardsModuleRoutes( 559 + authMiddleware: AuthMiddleware, 560 + // ... 561 + yourController: YourController, 562 + ): Router { 563 + // ... 564 + 565 + // 3. Pass to createCardRoutes 566 + router.use( 567 + '/cards', 568 + createCardRoutes( 569 + authMiddleware, 570 + // ... 571 + yourController, 572 + ), 573 + ); 574 + 575 + return router; 576 + } 577 + ``` 578 + 579 + ### 6. Wire through app 580 + 581 + Update `src/shared/infrastructure/http/app.ts`: 582 + 583 + ```typescript 584 + const cardsRouter = createCardsModuleRoutes( 585 + services.authMiddleware, 586 + // ... 587 + controllers.yourController, 588 + ); 589 + ``` 590 + 591 + ## Best Practices 592 + 593 + ### 1. Value Object Validation 594 + Always validate and create value objects early in the use case: 595 + ```typescript 596 + const curatorIdResult = CuratorId.create(request.curatorId); 597 + if (curatorIdResult.isErr()) { 598 + return err(new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`)); 599 + } 600 + const curatorId = curatorIdResult.value; 601 + ``` 602 + 603 + ### 2. Error Handling 604 + - Use `ValidationError` for business rule violations 605 + - Use `AppError.UnexpectedError` for infrastructure errors 606 + - Don't fail operations if event publishing fails (log instead) 607 + 608 + ```typescript 609 + const publishResult = await this.publishEventsForAggregate(entity); 610 + if (publishResult.isErr()) { 611 + console.error('Failed to publish events:', publishResult.error); 612 + // Don't fail the operation 613 + } 614 + ``` 615 + 616 + ### 3. Domain Services 617 + Use domain services for cross-aggregate operations: 618 + ```typescript 619 + // ✅ Good - uses domain service 620 + await this.cardCollectionService.addCardToCollections(card, collectionIds, curatorId); 621 + 622 + // ❌ Bad - directly manipulating aggregates 623 + collection.addCard(card.cardId, curatorId); 624 + await this.collectionRepository.save(collection); 625 + ``` 626 + 627 + ### 4. Response DTOs 628 + Return detailed information about what changed: 629 + ```typescript 630 + return ok({ 631 + id: entity.id.getStringValue(), 632 + created: !existingEntity, 633 + updated: !!existingEntity, 634 + affectedCollections: updatedCollections.map(c => c.id.getStringValue()), 635 + }); 636 + ``` 637 + 638 + ### 5. Idempotency 639 + Design use cases to be idempotent when possible: 640 + - Check existence before creating 641 + - Only update if values actually changed 642 + - Handle "already exists" gracefully 643 + 644 + ## Common Patterns Summary 645 + 646 + | Pattern | When to Use | Key Characteristics | 647 + |---------|-------------|---------------------| 648 + | **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove | 649 + | **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist | 650 + | **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation | 651 + | **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions | 652 + 653 + ## References 654 + 655 + ### Example Implementations 656 + - **Upsert**: `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts:147-234` 657 + - **Explicit Control**: `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts` 658 + - **Repository Methods**: `src/modules/cards/domain/ICardRepository.ts:15-18` 659 + 660 + ### Related Guides 661 + - Domain-Driven Design patterns 662 + - Repository pattern 663 + - Event publishing 664 + - Integration testing