Skip to content

Repository vs Query Selection

In Functorium CQRS, data access is divided into two paths: IRepository (Command side) and IQueryPort (Query side). This guide helps you choose the right path depending on the situation.


Check how the design purposes and usage patterns of the two interfaces differ.

CharacteristicIRepositoryIQueryPort
PurposeAggregate Root-level persistenceRead-only queries
TargetAggregateRoot<TId>DTO projection
Return typeFinT<IO, TAggregate>FinT<IO, PagedResult<TDto>>
MethodsCreate, GetById, Update, DeleteSearch, SearchByCursor, Stream
TransactionUsed with IUnitOfWorkNot needed
PaginationNone (ID-based lookup)Offset, Cursor, Stream
SpecificationNot usedUsed as search criteria
ImplementationsInMemoryRepositoryBase, EfCoreRepositoryBaseInMemoryQueryBase, DapperQueryBase

Specification<T> is the core search parameter for IQueryPort. The Search, SearchByCursor, and Stream methods all accept Specification<TEntity> as their first parameter to perform dynamic filtering. The And, Or, and Not combinations of Specification are used to compose complex search conditions on the Query side.

For detailed learning on the Specification pattern, see Implementing Domain Rules with the Specification Pattern.


Use it when you need to modify data or execute domain logic.

SituationReason
Data create/update/deleteRepository is Command-only
Lookup by ID then modifyGetById -> domain logic -> Update
Domain invariant validation neededExecute Aggregate Root business rules
Transaction-required operationsUsed with IUnitOfWork
Domain event publishingCollect domain events from AggregateRoot
// IRepository usage example: Cancel order (Command)
public class CancelOrderUsecase(
IRepository<Order, OrderId> repository)
: ICommandUsecase<CancelOrderCommand, OrderId>
{
public async ValueTask<FinResponse<OrderId>> Handle(
CancelOrderCommand command, CancellationToken ct)
{
var pipeline =
from order in repository.GetById(command.OrderId)
from _ in guard(order.CanCancel(), Error.New("Cannot cancel"))
from __ in repository.Update(order.Cancel())
select order.Id;
var fin = await pipeline.RunAsync();
return fin.ToFinResponse();
}
}

Use it for read-only queries, especially when lists/search/aggregation are needed.

SituationReason
List queriesPagination + sorting support
Search featuresSpecification-based dynamic filtering
DTO projectionReturn only the needed fields
Queries requiring joinsData from multiple tables into a single DTO
Large data streamingMemory-efficient queries with Stream method
Read performance optimizationDirect SQL control with Dapper etc.
// IQueryPort usage example: Search order list (Query)
public class SearchOrdersUsecase(
IQueryPort<Order, OrderDto> query)
: IQueryUsecase<SearchOrdersQuery, PagedResult<OrderDto>>
{
public async ValueTask<FinResponse<PagedResult<OrderDto>>> Handle(
SearchOrdersQuery request, CancellationToken ct)
{
var spec = BuildSpec(request);
var fin = await query.Search(spec, request.Page, request.Sort).RunAsync();
return fin.ToFinResponse();
}
private static Specification<Order> BuildSpec(SearchOrdersQuery request)
{
var spec = Specification<Order>.All;
if (request.CustomerId is not null)
spec &= new OrderByCustomerSpec(request.CustomerId.Value);
if (request.Status is not null)
spec &= new OrderByStatusSpec(request.Status.Value);
return spec;
}
}

Are you modifying data? (Create/Update/Delete)
├── Yes -> IRepository
│ └── Manage transactions with IUnitOfWork
└── No (read-only)
├── Lookup by ID then execute business logic?
│ ├── Yes -> IRepository.GetById
│ └── No -> Continue
├── List query + pagination?
│ └── Yes -> IQueryPort.Search / SearchByCursor
├── Large data streaming?
│ └── Yes -> IQueryPort.Stream
└── Simple DTO projection?
└── Yes -> IQueryPort.Search

A summary of which path to choose for frequently encountered real-world scenarios.

ScenarioChoiceReason
Create orderIRepository.CreateAggregate creation + invariant validation
Change order statusIRepository.GetById + UpdateDomain logic execution needed
Order list queryIQueryPort.SearchPagination + DTO projection
Order detail view (display)IQueryPort.SearchJoined DTO needed
Order detail view (for editing)IRepository.GetByIdDomain model needed
Order searchIQueryPort.SearchSpecification-based dynamic filter
Dashboard aggregationIQueryPort.SearchRead-only DTO
Data exportIQueryPort.StreamLarge data streaming

// Anti-pattern: Querying lists with Repository's GetByIds
var ids = await GetAllOrderIds(); // Get entire ID list first
var orders = await repository.GetByIds(ids).RunAsync(); // Load entire Aggregates
var dtos = orders.Map(o => o.ToDto()); // Manual conversion
// Correct approach: Use IQueryPort
var result = await query.Search(spec, page, sort).RunAsync();

The Query side is read-only. Data changes must go through IRepository.

Mixing IRepository and IQueryPort in the Same Usecase

Section titled “Mixing IRepository and IQueryPort in the Same Usecase”
// Anti-pattern: Using IQueryPort in a Command Usecase
public class CreateOrderUsecase(
IRepository<Order, OrderId> repository,
IQueryPort<Order, OrderDto> query) // Mixing Query into Command
{ ... }
// Correct approach: Commands use only IRepository, Queries use only IQueryPort

Let’s review the complete API of the FinT and FinResponse functional types used in the Repository and Usecase layers.

-> Appendix C: FinT / FinResponse Type Reference