This repository has no description
1# Query Use Cases in DDD
2
3This document outlines how to design and implement query use cases in our Domain-Driven Design (DDD) architecture, following CQRS (Command Query Responsibility Segregation) principles.
4
5## Command vs Query Use Cases
6
7### Command Use Cases (Write Side)
8
9- **Purpose**: Modify state (create, update, delete)
10- **Returns**: Minimal data (usually just IDs or success/failure)
11- **Flow**: Go through domain entities and aggregates
12- **Rules**: Enforce business rules and invariants
13- **Examples**: `AddUrlToLibraryUseCase`, `CreateCollectionUseCase`, `UpdateNoteCardUseCase`
14
15### Query Use Cases (Read Side)
16
17- **Purpose**: Read data without modifying state
18- **Returns**: Rich data optimized for display
19- **Flow**: Can bypass domain entities for performance
20- **Focus**: Data projection and formatting
21- **Examples**: `GetMyCardsUseCase`, `GetCollectionDetailsUseCase`
22
23## Query Use Case Patterns
24
25### 1. Simple Query Through Repository
26
27Use when you need basic data retrieval with minimal complexity.
28
29```typescript
30export class GetMyCardsUseCase {
31 constructor(private cardRepository: ICardRepository) {}
32
33 async execute(request: { curatorId: string }): Promise<Result<CardDTO[]>> {
34 // Query through domain repository
35 const cards = await this.cardRepository.findByCuratorId(curatorId);
36 return ok(cards.map((card) => this.toDTO(card)));
37 }
38}
39```
40
41### 2. Dedicated Query Repository (Recommended)
42
43Use for most query scenarios where you need optimized read operations.
44
45```typescript
46export interface ICardQueryRepository {
47 findCardsByLibraryMember(curatorId: string): Promise<CardListDTO[]>;
48 findCardsInCollection(collectionId: string): Promise<CardListDTO[]>;
49}
50
51export class GetMyCardsUseCase {
52 constructor(private cardQueryRepo: ICardQueryRepository) {}
53
54 async execute(request: GetMyCardsQuery): Promise<Result<CardListDTO[]>> {
55 // Optimized read-only queries
56 const cards = await this.cardQueryRepo.findCardsByLibraryMember(
57 request.curatorId,
58 );
59 return ok(cards);
60 }
61}
62```
63
64### 3. Query Service with Projections
65
66Use when you need to combine data from multiple sources or create complex projections.
67
68```typescript
69export class CardQueryService {
70 constructor(
71 private cardQueryRepo: ICardQueryRepository,
72 private collectionQueryRepo: ICollectionQueryRepository,
73 ) {}
74
75 async getMyCardsWithCollections(
76 curatorId: string,
77 ): Promise<EnrichedCardDTO[]> {
78 // Join data from multiple sources
79 const cards = await this.cardQueryRepo.findCardsByLibraryMember(curatorId);
80 const collections =
81 await this.collectionQueryRepo.findByCuratorId(curatorId);
82
83 // Project into view model
84 return this.enrichCardsWithCollections(cards, collections);
85 }
86}
87```
88
89## DTOs: Command vs Query
90
91### Command DTOs (Minimal)
92
93Focus on the data needed to perform the operation.
94
95```typescript
96export interface AddUrlToLibraryDTO {
97 url: string;
98 note?: string;
99 curatorId: string;
100}
101```
102
103### Query DTOs (Rich for Display)
104
105Focus on data optimized for UI consumption.
106
107```typescript
108export interface CardListItemDTO {
109 id: string;
110 type: 'URL' | 'NOTE' | 'HIGHLIGHT';
111 title: string;
112 preview: string;
113 createdAt: Date;
114 collections: string[];
115 isInLibrary: boolean;
116 metadata?: {
117 url?: string;
118 author?: string;
119 siteName?: string;
120 };
121}
122```
123
124## Composite Queries: Single vs Multiple Use Cases
125
126When designing queries that need multiple pieces of related data (like a collection page that shows collection details + cards), you have two main options:
127
128### Option 1: Single Composite Use Case (Recommended)
129
130Use when:
131
132- The data represents a **cohesive business concept** (e.g., "collection page view")
133- The UI needs the data **atomically** (all or nothing)
134- The data has **natural relationships** that are always needed together
135- **Performance benefits** from single query with joins
136
137```typescript
138export interface GetCollectionPageQuery {
139 collectionId: string;
140 curatorId: string;
141 // Card pagination
142 cardPage?: number;
143 cardLimit?: number;
144 cardSearchTerm?: string;
145}
146
147export interface GetCollectionPageResult {
148 collection: CollectionDetailsDTO;
149 cards: {
150 items: CardListItemDTO[];
151 totalCount: number;
152 hasMore: boolean;
153 currentPage: number;
154 };
155 userPermissions: {
156 canEdit: boolean;
157 canAddCards: boolean;
158 canRemoveCards: boolean;
159 };
160}
161
162export class GetCollectionPageUseCase {
163 constructor(
164 private collectionQueryRepo: ICollectionQueryRepository,
165 private cardQueryRepo: ICardQueryRepository,
166 ) {}
167
168 async execute(
169 query: GetCollectionPageQuery,
170 ): Promise<Result<GetCollectionPageResult>> {
171 // Get collection details
172 const collection = await this.collectionQueryRepo.findByIdWithPermissions(
173 query.collectionId,
174 query.curatorId,
175 );
176
177 // Get paginated cards in collection
178 const cardsResult = await this.cardQueryRepo.findCardsInCollection(
179 query.collectionId,
180 {
181 page: query.cardPage || 1,
182 limit: query.cardLimit || 20,
183 searchTerm: query.cardSearchTerm,
184 },
185 );
186
187 return ok({
188 collection: collection.details,
189 cards: cardsResult,
190 userPermissions: collection.permissions,
191 });
192 }
193}
194```
195
196### Option 2: Separate Use Cases with Composition
197
198Use when:
199
200- Different **rates of change** (collection details change rarely, cards change often)
201- Different **caching strategies** needed
202- **Independent reusability** (cards list used elsewhere)
203- Different **authorization rules** for each data type
204
205```typescript
206// Separate use cases
207export class GetCollectionDetailsUseCase {
208 async execute(query: { collectionId: string; curatorId: string }) {
209 // Just collection details and permissions
210 }
211}
212
213export class GetCollectionCardsUseCase {
214 async execute(query: { collectionId: string; page: number; limit: number }) {
215 // Just paginated cards
216 }
217}
218
219// Composition at the application service level
220export class CollectionPageService {
221 constructor(
222 private getCollectionDetails: GetCollectionDetailsUseCase,
223 private getCollectionCards: GetCollectionCardsUseCase,
224 ) {}
225
226 async getCollectionPage(query: GetCollectionPageQuery) {
227 const [collection, cards] = await Promise.all([
228 this.getCollectionDetails.execute({
229 collectionId: query.collectionId,
230 curatorId: query.curatorId,
231 }),
232 this.getCollectionCards.execute({
233 collectionId: query.collectionId,
234 page: query.cardPage || 1,
235 limit: query.cardLimit || 20,
236 }),
237 ]);
238
239 return { collection: collection.value, cards: cards.value };
240 }
241}
242```
243
244## Query Repository Implementation
245
246Query repositories can be optimized for read performance and bypass domain entities:
247
248```typescript
249export class SqlCardQueryRepository implements ICardQueryRepository {
250 async findCardsByLibraryMember(curatorId: string): Promise<CardListDTO[]> {
251 // Raw SQL for performance, bypassing domain entities
252 return this.db.query(
253 `
254 SELECT c.id, c.type, c.title, c.created_at,
255 array_agg(col.name) as collections
256 FROM cards c
257 JOIN library_memberships lm ON c.id = lm.card_id
258 LEFT JOIN card_collection_links ccl ON c.id = ccl.card_id
259 LEFT JOIN collections col ON ccl.collection_id = col.id
260 WHERE lm.curator_id = $1
261 GROUP BY c.id
262 `,
263 [curatorId],
264 );
265 }
266
267 async findByIdWithCards(
268 collectionId: string,
269 curatorId: string,
270 cardPagination: PaginationOptions,
271 ): Promise<CollectionPageData> {
272 // Single optimized query with joins
273 const query = `
274 SELECT
275 c.id, c.name, c.description, c.access_type,
276 c.author_id, c.created_at,
277 cards.id as card_id, cards.type, cards.title,
278 COUNT(*) OVER() as total_cards
279 FROM collections c
280 LEFT JOIN card_collection_links ccl ON c.id = ccl.collection_id
281 LEFT JOIN cards ON ccl.card_id = cards.id
282 WHERE c.id = $1
283 ORDER BY cards.created_at DESC
284 LIMIT $2 OFFSET $3
285 `;
286
287 // Transform to DTOs...
288 }
289}
290```
291
292## Best Practices
293
294### 1. Design for the UI
295
296- Structure query results to match what the UI actually needs
297- Include computed fields and aggregations
298- Consider pagination from the start
299
300### 2. Optimize for Performance
301
302- Use dedicated query repositories that can leverage database-specific optimizations
303- Consider denormalized views for complex queries
304- Use appropriate indexing strategies
305
306### 3. Handle Pagination Consistently
307
308```typescript
309export interface PaginationOptions {
310 page: number;
311 limit: number;
312 searchTerm?: string;
313}
314
315export interface PaginatedResult<T> {
316 items: T[];
317 totalCount: number;
318 hasMore: boolean;
319 currentPage: number;
320}
321```
322
323### 4. Separate Query Models from Domain Models
324
325- Query DTOs should be optimized for display, not domain logic
326- Don't expose internal domain structure through query results
327- Use mapping/projection layers
328
329### 5. Consider Caching
330
331- Query results are often good candidates for caching
332- Design cache keys that can be invalidated when related commands execute
333- Consider different cache strategies for different query types
334
335## Example: Complete Query Use Case
336
337```typescript
338export interface GetMyCardsQuery {
339 curatorId: string;
340 page?: number;
341 limit?: number;
342 type?: CardTypeEnum;
343 collectionId?: string;
344 searchTerm?: string;
345}
346
347export interface GetMyCardsResult {
348 cards: CardListItemDTO[];
349 totalCount: number;
350 hasMore: boolean;
351 filters: {
352 availableTypes: CardTypeEnum[];
353 availableCollections: { id: string; name: string }[];
354 };
355}
356
357export class GetMyCardsUseCase {
358 constructor(
359 private cardQueryRepo: ICardQueryRepository,
360 private collectionQueryRepo: ICollectionQueryRepository,
361 ) {}
362
363 async execute(query: GetMyCardsQuery): Promise<Result<GetMyCardsResult>> {
364 // Validate curator ID
365 const curatorIdResult = CuratorId.create(query.curatorId);
366 if (curatorIdResult.isErr()) {
367 return err(new ValidationError('Invalid curator ID'));
368 }
369
370 // Get paginated cards
371 const cardsResult = await this.cardQueryRepo.findCardsByLibraryMember(
372 query.curatorId,
373 {
374 page: query.page || 1,
375 limit: query.limit || 20,
376 type: query.type,
377 collectionId: query.collectionId,
378 searchTerm: query.searchTerm,
379 },
380 );
381
382 // Get filter options
383 const collections = await this.collectionQueryRepo.findByCuratorId(
384 query.curatorId,
385 );
386
387 return ok({
388 cards: cardsResult.items,
389 totalCount: cardsResult.totalCount,
390 hasMore: cardsResult.hasMore,
391 filters: {
392 availableTypes: [
393 CardTypeEnum.URL,
394 CardTypeEnum.NOTE,
395 CardTypeEnum.HIGHLIGHT,
396 ],
397 availableCollections: collections.map((c) => ({
398 id: c.id,
399 name: c.name,
400 })),
401 },
402 });
403 }
404}
405```
406
407This approach treats queries as first-class citizens in your domain, optimized for the specific needs of your application's read scenarios.