A social knowledge tool for researchers built on ATProto

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)));
  }
}

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:

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.