Query Use Cases in DDD#
This document outlines how to design and implement query use cases in our Domain-Driven Design (DDD) architecture, following CQRS (Command Query Responsibility Segregation) principles.
Command vs Query Use Cases#
Command Use Cases (Write Side)#
- Purpose: Modify state (create, update, delete)
- Returns: Minimal data (usually just IDs or success/failure)
- Flow: Go through domain entities and aggregates
- Rules: Enforce business rules and invariants
- Examples:
AddUrlToLibraryUseCase,CreateCollectionUseCase,UpdateNoteCardUseCase
Query Use Cases (Read Side)#
- Purpose: Read data without modifying state
- Returns: Rich data optimized for display
- Flow: Can bypass domain entities for performance
- Focus: Data projection and formatting
- Examples:
GetMyCardsUseCase,GetCollectionDetailsUseCase
Query Use Case Patterns#
1. Simple Query Through Repository#
Use when you need basic data retrieval with minimal complexity.
export class GetMyCardsUseCase {
constructor(private cardRepository: ICardRepository) {}
async execute(request: { curatorId: string }): Promise<Result<CardDTO[]>> {
// Query through domain repository
const cards = await this.cardRepository.findByCuratorId(curatorId);
return ok(cards.map((card) => this.toDTO(card)));
}
}
2. Dedicated Query Repository (Recommended)#
Use for most query scenarios where you need optimized read operations.
export interface ICardQueryRepository {
findCardsByLibraryMember(curatorId: string): Promise<CardListDTO[]>;
findCardsInCollection(collectionId: string): Promise<CardListDTO[]>;
}
export class GetMyCardsUseCase {
constructor(private cardQueryRepo: ICardQueryRepository) {}
async execute(request: GetMyCardsQuery): Promise<Result<CardListDTO[]>> {
// Optimized read-only queries
const cards = await this.cardQueryRepo.findCardsByLibraryMember(
request.curatorId,
);
return ok(cards);
}
}
3. Query Service with Projections#
Use when you need to combine data from multiple sources or create complex projections.
export class CardQueryService {
constructor(
private cardQueryRepo: ICardQueryRepository,
private collectionQueryRepo: ICollectionQueryRepository,
) {}
async getMyCardsWithCollections(
curatorId: string,
): Promise<EnrichedCardDTO[]> {
// Join data from multiple sources
const cards = await this.cardQueryRepo.findCardsByLibraryMember(curatorId);
const collections =
await this.collectionQueryRepo.findByCuratorId(curatorId);
// Project into view model
return this.enrichCardsWithCollections(cards, collections);
}
}
DTOs: Command vs Query#
Command DTOs (Minimal)#
Focus on the data needed to perform the operation.
export interface AddUrlToLibraryDTO {
url: string;
note?: string;
curatorId: string;
}
Query DTOs (Rich for Display)#
Focus on data optimized for UI consumption.
export interface CardListItemDTO {
id: string;
type: 'URL' | 'NOTE' | 'HIGHLIGHT';
title: string;
preview: string;
createdAt: Date;
collections: string[];
isInLibrary: boolean;
metadata?: {
url?: string;
author?: string;
siteName?: string;
};
}
Composite Queries: Single vs Multiple Use Cases#
When designing queries that need multiple pieces of related data (like a collection page that shows collection details + cards), you have two main options:
Option 1: Single Composite Use Case (Recommended)#
Use when:
- The data represents a cohesive business concept (e.g., "collection page view")
- The UI needs the data atomically (all or nothing)
- The data has natural relationships that are always needed together
- Performance benefits from single query with joins
export interface GetCollectionPageQuery {
collectionId: string;
curatorId: string;
// Card pagination
cardPage?: number;
cardLimit?: number;
cardSearchTerm?: string;
}
export interface GetCollectionPageResult {
collection: CollectionDetailsDTO;
cards: {
items: CardListItemDTO[];
totalCount: number;
hasMore: boolean;
currentPage: number;
};
userPermissions: {
canEdit: boolean;
canAddCards: boolean;
canRemoveCards: boolean;
};
}
export class GetCollectionPageUseCase {
constructor(
private collectionQueryRepo: ICollectionQueryRepository,
private cardQueryRepo: ICardQueryRepository,
) {}
async execute(
query: GetCollectionPageQuery,
): Promise<Result<GetCollectionPageResult>> {
// Get collection details
const collection = await this.collectionQueryRepo.findByIdWithPermissions(
query.collectionId,
query.curatorId,
);
// Get paginated cards in collection
const cardsResult = await this.cardQueryRepo.findCardsInCollection(
query.collectionId,
{
page: query.cardPage || 1,
limit: query.cardLimit || 20,
searchTerm: query.cardSearchTerm,
},
);
return ok({
collection: collection.details,
cards: cardsResult,
userPermissions: collection.permissions,
});
}
}
Option 2: Separate Use Cases with Composition#
Use when:
- Different rates of change (collection details change rarely, cards change often)
- Different caching strategies needed
- Independent reusability (cards list used elsewhere)
- Different authorization rules for each data type
// Separate use cases
export class GetCollectionDetailsUseCase {
async execute(query: { collectionId: string; curatorId: string }) {
// Just collection details and permissions
}
}
export class GetCollectionCardsUseCase {
async execute(query: { collectionId: string; page: number; limit: number }) {
// Just paginated cards
}
}
// Composition at the application service level
export class CollectionPageService {
constructor(
private getCollectionDetails: GetCollectionDetailsUseCase,
private getCollectionCards: GetCollectionCardsUseCase,
) {}
async getCollectionPage(query: GetCollectionPageQuery) {
const [collection, cards] = await Promise.all([
this.getCollectionDetails.execute({
collectionId: query.collectionId,
curatorId: query.curatorId,
}),
this.getCollectionCards.execute({
collectionId: query.collectionId,
page: query.cardPage || 1,
limit: query.cardLimit || 20,
}),
]);
return { collection: collection.value, cards: cards.value };
}
}
Query Repository Implementation#
Query repositories can be optimized for read performance and bypass domain entities:
export class SqlCardQueryRepository implements ICardQueryRepository {
async findCardsByLibraryMember(curatorId: string): Promise<CardListDTO[]> {
// Raw SQL for performance, bypassing domain entities
return this.db.query(
`
SELECT c.id, c.type, c.title, c.created_at,
array_agg(col.name) as collections
FROM cards c
JOIN library_memberships lm ON c.id = lm.card_id
LEFT JOIN card_collection_links ccl ON c.id = ccl.card_id
LEFT JOIN collections col ON ccl.collection_id = col.id
WHERE lm.curator_id = $1
GROUP BY c.id
`,
[curatorId],
);
}
async findByIdWithCards(
collectionId: string,
curatorId: string,
cardPagination: PaginationOptions,
): Promise<CollectionPageData> {
// Single optimized query with joins
const query = `
SELECT
c.id, c.name, c.description, c.access_type,
c.author_id, c.created_at,
cards.id as card_id, cards.type, cards.title,
COUNT(*) OVER() as total_cards
FROM collections c
LEFT JOIN card_collection_links ccl ON c.id = ccl.collection_id
LEFT JOIN cards ON ccl.card_id = cards.id
WHERE c.id = $1
ORDER BY cards.created_at DESC
LIMIT $2 OFFSET $3
`;
// Transform to DTOs...
}
}
Best Practices#
1. Design for the UI#
- Structure query results to match what the UI actually needs
- Include computed fields and aggregations
- Consider pagination from the start
2. Optimize for Performance#
- Use dedicated query repositories that can leverage database-specific optimizations
- Consider denormalized views for complex queries
- Use appropriate indexing strategies
3. Handle Pagination Consistently#
export interface PaginationOptions {
page: number;
limit: number;
searchTerm?: string;
}
export interface PaginatedResult<T> {
items: T[];
totalCount: number;
hasMore: boolean;
currentPage: number;
}
4. Separate Query Models from Domain Models#
- Query DTOs should be optimized for display, not domain logic
- Don't expose internal domain structure through query results
- Use mapping/projection layers
5. Consider Caching#
- Query results are often good candidates for caching
- Design cache keys that can be invalidated when related commands execute
- Consider different cache strategies for different query types
Example: Complete Query Use Case#
export interface GetMyCardsQuery {
curatorId: string;
page?: number;
limit?: number;
type?: CardTypeEnum;
collectionId?: string;
searchTerm?: string;
}
export interface GetMyCardsResult {
cards: CardListItemDTO[];
totalCount: number;
hasMore: boolean;
filters: {
availableTypes: CardTypeEnum[];
availableCollections: { id: string; name: string }[];
};
}
export class GetMyCardsUseCase {
constructor(
private cardQueryRepo: ICardQueryRepository,
private collectionQueryRepo: ICollectionQueryRepository,
) {}
async execute(query: GetMyCardsQuery): Promise<Result<GetMyCardsResult>> {
// Validate curator ID
const curatorIdResult = CuratorId.create(query.curatorId);
if (curatorIdResult.isErr()) {
return err(new ValidationError('Invalid curator ID'));
}
// Get paginated cards
const cardsResult = await this.cardQueryRepo.findCardsByLibraryMember(
query.curatorId,
{
page: query.page || 1,
limit: query.limit || 20,
type: query.type,
collectionId: query.collectionId,
searchTerm: query.searchTerm,
},
);
// Get filter options
const collections = await this.collectionQueryRepo.findByCuratorId(
query.curatorId,
);
return ok({
cards: cardsResult.items,
totalCount: cardsResult.totalCount,
hasMore: cardsResult.hasMore,
filters: {
availableTypes: [
CardTypeEnum.URL,
CardTypeEnum.NOTE,
CardTypeEnum.HIGHLIGHT,
],
availableCollections: collections.map((c) => ({
id: c.id,
name: c.name,
})),
},
});
}
}
This approach treats queries as first-class citizens in your domain, optimized for the specific needs of your application's read scenarios.