This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / docs / query-use-cases.md
11 kB 407 lines
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.