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