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