+70
-26
.claude/guides/use-case-implementation-patterns.md
+70
-26
.claude/guides/use-case-implementation-patterns.md
···
3
3
This guide documents the patterns and best practices for implementing use cases in the Semble application, with full vertical stack integration.
4
4
5
5
## Table of Contents
6
+
6
7
1. [Use Case Structure](#use-case-structure)
7
8
2. [Repository Patterns](#repository-patterns)
8
9
3. [Upsert Behavior Pattern](#upsert-behavior-pattern)
···
100
101
When you need to find related entities (e.g., note cards for a URL card), add specific repository methods:
101
102
102
103
#### Interface Definition
104
+
103
105
```typescript
104
106
// src/modules/cards/domain/ICardRepository.ts
105
107
export interface ICardRepository {
106
108
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
+
findUsersUrlCardByUrl(
110
+
url: URL,
111
+
curatorId: CuratorId,
112
+
): Promise<Result<Card | null>>;
113
+
findUsersNoteCardByUrl(
114
+
url: URL,
115
+
curatorId: CuratorId,
116
+
): Promise<Result<Card | null>>;
109
117
save(card: Card): Promise<Result<void>>;
110
118
delete(cardId: CardId): Promise<Result<void>>;
111
119
}
112
120
```
113
121
114
122
#### In-Memory Implementation (for tests)
123
+
115
124
```typescript
116
125
async findUsersNoteCardByUrl(
117
126
url: URL,
···
132
141
```
133
142
134
143
#### Drizzle Implementation
144
+
135
145
```typescript
136
146
async findUsersNoteCardByUrl(
137
147
url: URL,
···
170
180
### Example: AddUrlToLibraryUseCase
171
181
172
182
This use case demonstrates modified upsert behavior:
183
+
173
184
- If URL card doesn't exist: Create it
174
185
- If URL card exists: Reuse it
175
186
- If note is provided and note card exists: **Update** the note
···
178
189
179
190
```typescript
180
191
// Check if note card already exists
181
-
const existingNoteCardResult =
182
-
await this.cardRepository.findUsersNoteCardByUrl(url, curatorId);
192
+
const existingNoteCardResult = await this.cardRepository.findUsersNoteCardByUrl(
193
+
url,
194
+
curatorId,
195
+
);
183
196
if (existingNoteCardResult.isErr()) {
184
197
return err(AppError.UnexpectedError.create(existingNoteCardResult.error));
185
198
}
···
227
240
const url = 'https://example.com/existing';
228
241
229
242
// First request creates URL card with note
230
-
const firstRequest = { url, note: 'Original note', curatorId: curatorId.value };
243
+
const firstRequest = {
244
+
url,
245
+
note: 'Original note',
246
+
curatorId: curatorId.value,
247
+
};
231
248
const firstResult = await useCase.execute(firstRequest);
232
249
expect(firstResult.isOk()).toBe(true);
233
250
const firstResponse = firstResult.unwrap();
234
251
235
252
// Second request updates the note
236
-
const secondRequest = { url, note: 'Updated note', curatorId: curatorId.value };
253
+
const secondRequest = {
254
+
url,
255
+
note: 'Updated note',
256
+
curatorId: curatorId.value,
257
+
};
237
258
const secondResult = await useCase.execute(secondRequest);
238
259
expect(secondResult.isOk()).toBe(true);
239
260
const secondResponse = secondResult.unwrap();
···
257
278
### Example: UpdateUrlCardAssociationsUseCase
258
279
259
280
This use case demonstrates explicit control:
281
+
260
282
- Requires URL card to already exist
261
283
- Provides separate controls for adding vs removing collections
262
284
- Can create or update notes
···
267
289
url: string;
268
290
curatorId: string;
269
291
note?: string;
270
-
addToCollections?: string[]; // Explicit add
292
+
addToCollections?: string[]; // Explicit add
271
293
removeFromCollections?: string[]; // Explicit remove
272
294
}
273
295
274
296
export interface UpdateUrlCardAssociationsResponseDTO {
275
297
urlCardId: string;
276
298
noteCardId?: string;
277
-
addedToCollections: string[]; // What was actually added
299
+
addedToCollections: string[]; // What was actually added
278
300
removedFromCollections: string[]; // What was actually removed
279
301
}
280
302
···
352
374
const response = result.unwrap();
353
375
expect(response.addedToCollections).toHaveLength(2);
354
376
expect(response.removedFromCollections).toHaveLength(1);
355
-
expect(response.addedToCollections).toContain(collection2.collectionId.getStringValue());
356
-
expect(response.removedFromCollections).toContain(collection1.collectionId.getStringValue());
377
+
expect(response.addedToCollections).toContain(
378
+
collection2.collectionId.getStringValue(),
379
+
);
380
+
expect(response.removedFromCollections).toContain(
381
+
collection1.collectionId.getStringValue(),
382
+
);
357
383
});
358
384
```
359
385
···
373
399
eventPublisher = new FakeEventPublisher();
374
400
domainService = new DomainService(repository, eventPublisher);
375
401
376
-
useCase = new YourUseCase(
377
-
repository,
378
-
domainService,
379
-
eventPublisher,
380
-
);
402
+
useCase = new YourUseCase(repository, domainService, eventPublisher);
381
403
});
382
404
383
405
afterEach(() => {
···
388
410
describe('Feature group 1', () => {
389
411
it('should do X when Y', async () => {
390
412
// Arrange
391
-
const request = { /* ... */ };
413
+
const request = {
414
+
/* ... */
415
+
};
392
416
393
417
// Act
394
418
const result = await useCase.execute(request);
···
401
425
402
426
describe('Validation', () => {
403
427
it('should fail with invalid input', async () => {
404
-
const result = await useCase.execute({ /* invalid */ });
428
+
const result = await useCase.execute({
429
+
/* invalid */
430
+
});
405
431
406
432
expect(result.isErr()).toBe(true);
407
433
if (result.isErr()) {
···
415
441
### Test Coverage Checklist
416
442
417
443
For each use case, ensure tests cover:
444
+
418
445
- ✅ Happy path (basic functionality)
419
446
- ✅ Update existing entities
420
447
- ✅ Create new entities
···
591
618
## Best Practices
592
619
593
620
### 1. Value Object Validation
621
+
594
622
Always validate and create value objects early in the use case:
623
+
595
624
```typescript
596
625
const curatorIdResult = CuratorId.create(request.curatorId);
597
626
if (curatorIdResult.isErr()) {
598
-
return err(new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`));
627
+
return err(
628
+
new ValidationError(`Invalid curator ID: ${curatorIdResult.error.message}`),
629
+
);
599
630
}
600
631
const curatorId = curatorIdResult.value;
601
632
```
602
633
603
634
### 2. Error Handling
635
+
604
636
- Use `ValidationError` for business rule violations
605
637
- Use `AppError.UnexpectedError` for infrastructure errors
606
638
- Don't fail operations if event publishing fails (log instead)
···
614
646
```
615
647
616
648
### 3. Domain Services
649
+
617
650
Use domain services for cross-aggregate operations:
651
+
618
652
```typescript
619
653
// ✅ Good - uses domain service
620
-
await this.cardCollectionService.addCardToCollections(card, collectionIds, curatorId);
654
+
await this.cardCollectionService.addCardToCollections(
655
+
card,
656
+
collectionIds,
657
+
curatorId,
658
+
);
621
659
622
660
// ❌ Bad - directly manipulating aggregates
623
661
collection.addCard(card.cardId, curatorId);
···
625
663
```
626
664
627
665
### 4. Response DTOs
666
+
628
667
Return detailed information about what changed:
668
+
629
669
```typescript
630
670
return ok({
631
671
id: entity.id.getStringValue(),
632
672
created: !existingEntity,
633
673
updated: !!existingEntity,
634
-
affectedCollections: updatedCollections.map(c => c.id.getStringValue()),
674
+
affectedCollections: updatedCollections.map((c) => c.id.getStringValue()),
635
675
});
636
676
```
637
677
638
678
### 5. Idempotency
679
+
639
680
Design use cases to be idempotent when possible:
681
+
640
682
- Check existence before creating
641
683
- Only update if values actually changed
642
684
- Handle "already exists" gracefully
643
685
644
686
## Common Patterns Summary
645
687
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 |
688
+
| Pattern | When to Use | Key Characteristics |
689
+
| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
690
+
| **Upsert** | When user intent is "ensure this state" | - Create if not exists<br/>- Update if exists<br/>- Only add, never remove |
691
+
| **Explicit Control** | When user needs precise control | - Separate add/remove operations<br/>- Returns what changed<br/>- Requires entity to exist |
692
+
| **Create Only** | When creating new entities | - Fails if already exists<br/>- Simple validation |
693
+
| **Update Only** | When modifying existing entities | - Requires entity to exist<br/>- Validates ownership/permissions |
652
694
653
695
## References
654
696
655
697
### Example Implementations
698
+
656
699
- **Upsert**: `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts:147-234`
657
700
- **Explicit Control**: `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts`
658
701
- **Repository Methods**: `src/modules/cards/domain/ICardRepository.ts:15-18`
659
702
660
703
### Related Guides
704
+
661
705
- Domain-Driven Design patterns
662
706
- Repository pattern
663
707
- Event publishing
+1
-2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
+1
-2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
···
172
172
}
173
173
174
174
// Save updated note card
175
-
const saveNoteCardResult =
176
-
await this.cardRepository.save(noteCard);
175
+
const saveNoteCardResult = await this.cardRepository.save(noteCard);
177
176
if (saveNoteCardResult.isErr()) {
178
177
return err(
179
178
AppError.UnexpectedError.create(saveNoteCardResult.error),
+11
-11
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
+11
-11
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
···
76
76
const cardIdResult = CardId.createFromString(request.cardId);
77
77
if (cardIdResult.isErr()) {
78
78
return err(
79
-
new ValidationError(
80
-
`Invalid card ID: ${cardIdResult.error.message}`,
81
-
),
79
+
new ValidationError(`Invalid card ID: ${cardIdResult.error.message}`),
82
80
);
83
81
}
84
82
const cardId = cardIdResult.value;
···
103
101
// Verify it's a URL card
104
102
if (!urlCard.isUrlCard) {
105
103
return err(
106
-
new ValidationError('Card must be a URL card to update associations.'),
104
+
new ValidationError(
105
+
'Card must be a URL card to update associations.',
106
+
),
107
107
);
108
108
}
109
109
110
110
// Verify ownership
111
111
if (!urlCard.curatorId.equals(curatorId)) {
112
112
return err(
113
-
new ValidationError('You do not have permission to update this card.'),
113
+
new ValidationError(
114
+
'You do not have permission to update this card.',
115
+
),
114
116
);
115
117
}
116
118
117
119
// Get the URL from the card for note operations
118
120
if (!urlCard.url) {
119
-
return err(
120
-
new ValidationError('URL card must have a URL property.'),
121
-
);
121
+
return err(new ValidationError('URL card must have a URL property.'));
122
122
}
123
123
const url = urlCard.url;
124
124
···
152
152
}
153
153
154
154
// Save updated note card
155
-
const saveNoteCardResult =
156
-
await this.cardRepository.save(noteCard);
155
+
const saveNoteCardResult = await this.cardRepository.save(noteCard);
157
156
if (saveNoteCardResult.isErr()) {
158
157
return err(
159
158
AppError.UnexpectedError.create(saveNoteCardResult.error),
···
284
283
);
285
284
if (removeFromCollectionsResult.isErr()) {
286
285
if (
287
-
removeFromCollectionsResult.error instanceof AppError.UnexpectedError
286
+
removeFromCollectionsResult.error instanceof
287
+
AppError.UnexpectedError
288
288
) {
289
289
return err(removeFromCollectionsResult.error);
290
290
}