alpha
Login
or
Join now
nandi.uk
/
semble
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
include claude code guide
author
Wesley Finck
date
9 months ago
(Oct 14, 2025, 6:05 PM -0700)
commit
79cc7340
79cc73400a98bc9b3967ad8c85157d828fbf086b
parent
0b6d9f31
0b6d9f310ed44887e2d6208f616a6369a2e90d0b
+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
View file
Reviewed
···
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