Skip to content

Use Cases and CQRS

This document explains how to implement use cases with the CQRS pattern, which separates reads and writes for individual optimization.

“Should navigation properties be added to the domain model for query performance?” “As business logic in the Application Service keeps growing, how can it be separated?” “Where should SaveChanges be called, and who is responsible for publishing domain events?”

These are problems repeatedly encountered when designing the Application Layer. CQRS separates reads and writes so each can choose the optimal technology, and Functorium’s pipeline system automatically handles transactions and event publishing so that Use Cases can focus solely on business logic.

Through this document, you will learn:

  1. Benefits of the CQRS pattern and Command/Query separation criteria - Practical effects of separating read/write paths
  2. Implementing use cases with the nested class pattern - Cohesive grouping of Request, Response, Validator, and Usecase in a single file
  3. Apply merge pattern and LINQ-based functional implementation - Value Object validation and functional chaining
  4. Automatic handling by UsecaseTransactionPipeline - Automation of SaveChanges and domain event publishing
  5. Application errors and FluentValidation integration - Dual validation strategy

A basic understanding of the following concepts is required to understand this document:

The core of CQRS is separating reads and writes so each can choose the optimal technology, and Functorium’s pipeline automatically handles transactions and event publishing so that Use Cases can focus solely on business logic.

The Application Layer is the layer that orchestrates domain objects to perform use cases. It does not contain domain logic itself but delegates work to domain objects.

In a traditional Application Service, a single service class handles creation, retrieval, modification, and deletion. It looks concise at first, but problems emerge as the business grows.

Queries require DTOs that join multiple tables, while creation requires immutable validation through Aggregate Roots and transactions. If you try to satisfy both with a single model, you either add navigation properties to the domain model for query performance, or conversely make query code unnecessarily complex to preserve domain integrity.

CQRS solves this problem by separating the read path (Query) from the write path (Command). Commands persist Aggregates via EF Core, and Queries write SQL directly via Dapper, allowing each to choose the optimal technology.

The following table compares a unified model with CQRS. The key benefit is that Command and Query can each choose the optimal technology stack.

AspectUnified ModelCQRS
Read/Write optimizationCompromise with a single modelEach can be optimized independently
Technology stackSame ORM enforcedCommand: EF Core, Query: Dapper independently chosen
ScalabilityScale togetherScale independently
Complexity managementConcentrated in one placeSeparation of concerns

Technology Separation in the Adapter Layer

Section titled “Technology Separation in the Adapter Layer”

The benefits of CQRS are realized in the Adapter layer:

AspectCommandQuery
Adapter typeRepository (IRepository<T, TId>)Query Adapter (IQueryPort<TEntity, TDto>)
ORMEF CoreDapper + explicit SQL
ReasonChange tracking, UnitOfWork, migrationsMaximum performance, easy SQL tuning
Return typeDomain Entity (FinT<IO, T>)DTO (FinT<IO, PagedResult<TDto>>)
Port locationDomain LayerApplication Layer

For detailed implementation, see 13-adapters.md §2.6 Query Adapter

Use Case = Explicit Expression of Business Intent

Section titled “Use Case = Explicit Expression of Business Intent”

In Functorium, each use case is represented as a single class. The business intent is expressed in the class name, such as CreateProductCommand or GetProductByIdQuery.

PurposeRequest InterfaceHandler Interface
CommandICommandRequest<TSuccess>ICommandUsecase<TCommand, TSuccess>
QueryIQueryRequest<TSuccess>IQueryUsecase<TQuery, TSuccess>
EventIDomainEventIDomainEventHandler<TEvent>
TypePurposeLayer
Fin<A>LanguageExt success/failure typeDomain or Adapter
FinT<IO, A>Fin type with IO effectRepository/Adapter
FinResponse<A>Functorium Response success/failure typeUsecase
ErrorError informationCommon
ICacheableQuery caching marker interface (CacheKey, Duration)Usecase
using Functorium.Applications.Errors;
using static Functorium.Applications.Errors.ApplicationErrorType;
public sealed class CreateProductCommand
{
public sealed record Request(...) : ICommandRequest<Response>;
public sealed record Response(...);
public sealed class Validator : AbstractValidator<Request> { ... }
internal sealed class Usecase(
IProductRepository productRepository)
: ICommandUsecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(Request request, CancellationToken cancellationToken)
{
// 1. Value Object validation + Apply merge
var productResult = CreateProduct(request);
if (productResult.IsFail)
{
return productResult.Match(
Succ: _ => throw new InvalidOperationException(),
Fail: error => FinResponse.Fail<Response>(error));
}
// 2. Business logic processing via LINQ query
var productName = ProductName.Create(request.Name).Unwrap();
FinT<IO, Response> usecase =
from exists in _productRepository.ExistsByName(productName)
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
request.Name,
$"Product name already exists: '{request.Name}'"))
from product in _productRepository.Create((Product)productResult)
select new Response(...);
// SaveChanges + domain event publishing is handled automatically by UsecaseTransactionPipeline
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
private static Fin<Product> CreateProduct(Request request)
{
var name = ProductName.Validate(request.Name);
var description = ProductDescription.Validate(request.Description);
var price = Money.Validate(request.Price);
var stockQuantity = Quantity.Validate(request.StockQuantity);
return (name, description, price, stockQuantity)
.Apply((n, d, p, s) =>
Product.Create(
ProductName.Create(n).Unwrap(),
ProductDescription.Create(d).Unwrap(),
Money.Create(p).Unwrap(),
Quantity.Create(s).Unwrap()))
.As()
.ToFin();
}
}
}
CriteriaUnwrapApplyT
Number of VOs1-23 or more
Error handlingReturns immediately at the first errorCollects all errors in parallel
Code styleImperative (var x = ...)Declarative (LINQ from)
Learning curveLowHigh (monad transformers)
Suitable forSimple Commands, internal servicesUser input forms, complex validation

Decision criteria: If there are 1-2 VOs and no need to collect errors in parallel, Unwrap is more concise. If there are 3 or more VOs, or you need to show all validation errors to the user at once, use ApplyT.

Now that we have grasped the overall structure from the summary, let us examine the specific structure of the CQRS pattern.


CategoryCommandQuery
PurposeState change (write)Data query (read)
ExampleCreate, Update, DeleteGetById, GetAll, Search
ReturnCreated/modified entity infoRetrieved data

Functorium CQRS is based on the Mediator library:

// Request inherits ICommand or IQuery
public interface ICommandRequest<TSuccess> : ICommand<FinResponse<TSuccess>> { }
public interface IQueryRequest<TSuccess> : IQuery<FinResponse<TSuccess>> { }
// Handler inherits ICommandHandler or IQueryHandler
public interface ICommandUsecase<in TCommand, TSuccess>
: ICommandHandler<TCommand, FinResponse<TSuccess>>
where TCommand : ICommandRequest<TSuccess> { }

{Project}.Application/
├── Ports/
│ └── I{Interface}.cs # Technical concern interface
└── Usecases/
├── {Entity}/
│ ├── Create{Entity}Command.cs # Command Use Case
│ ├── Update{Entity}Command.cs # Command Use Case
│ ├── Get{Entity}ByIdQuery.cs # Query Use Case
│ ├── GetAll{Entity}sQuery.cs # Query Use Case
│ ├── On{Entity}Created.cs # Event Use Case
│ └── On{Entity}Updated.cs # Event Use Case
└── ...

Note: Event Handlers are also a type of Use Case. As Event-Driven Use Cases, they are placed in the same folder alongside Commands/Queries.

We have confirmed the overall structure of the CQRS pattern and Mediator integration. The next section examines the nested class pattern that composes a single use case.


Request, Response, Validator, and Usecase composing a single use case are defined as nested classes in one file.

Advantages:

  • Related code is gathered in one place, improving cohesion
  • The entire use case can be understood without navigating files
  • Prevents naming conflicts (CreateProductCommand.Request vs UpdateProductCommand.Request)
/// <summary>
/// {Feature description}
/// </summary>
public sealed class {Verb}{Entity}{Command|Query}
{
/// <summary>
/// {Command|Query} Request - {Request data description}
/// </summary>
public sealed record Request(...) : I{Command|Query}Request<Response>;
/// <summary>
/// {Command|Query} Response - {Response data description}
/// </summary>
public sealed record Response(...);
/// <summary>
/// Request Validator - FluentValidation validation rules (optional)
/// </summary>
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
// Define validation rules
}
}
/// <summary>
/// {Command|Query} Handler - {Business logic description}
/// </summary>
internal sealed class Usecase(...) : I{Command|Query}Usecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(
Request request,
CancellationToken cancellationToken)
{
// Implementation (executed after Validator passes)
// Application error: use ApplicationError.For<{UsecaseName}>(new {ErrorType}(), value, message)
}
}
}
ClassAccess ModifierRequiredDescription
RequestpublicRequiredInput data definition
ResponsepublicRequiredOutput data definition
ValidatorpublicOptionalFluentValidation validation rules
UsecaseinternalRequiredBusiness logic implementation

Note: When a Validator is defined, it is automatically validated before Handler execution through the Pipeline.

We now understand the nested class structure. The next section covers the Apply merge pattern for simultaneously validating multiple Value Objects and creating Entities within a Usecase.


Value Object Validation and Apply Merge Pattern

Section titled “Value Object Validation and Apply Merge Pattern”

There are two validation layers in a Usecase. FluentValidation handles fast format validation, while Value Objects handle domain invariant validation.

Validation LayerResponsiblePurpose
FluentValidationPresentation LayerFast input format validation
Value Object Validate()Domain LayerDomain invariant validation

The Apply pattern is used when validating multiple Value Objects simultaneously and creating an Entity.

The key point in the following code is that all fields are first validated with Validate(), then parallel validation results are merged with Apply(), and already-validated values are safely converted with Unwrap().

private static Fin<Product> CreateProduct(Request request)
{
// 1. All fields: call VO Validate() (returns Validation<Error, T>)
var name = ProductName.Validate(request.Name);
var description = ProductDescription.Validate(request.Description);
var price = Money.Validate(request.Price);
var stockQuantity = Quantity.Validate(request.StockQuantity);
// 2. Parallel validation via Apply, then Entity creation
return (name, description, price, stockQuantity)
.Apply((n, d, p, s) =>
Product.Create(
ProductName.Create(n).Unwrap(),
ProductDescription.Create(d).Unwrap(),
Money.Create(p).Unwrap(),
Quantity.Create(s).Unwrap()))
.As()
.ToFin();
}
StepMethodDescription
1Validate()Collect validation of all fields as Validation<Error, T>
2Apply()All validations must succeed before Entity creation proceeds (parallel validation)
3Unwrap()Since values are already validated, safely convert to VO
4As().ToFin()Convert Validation type to Fin type

When not all fields are defined as Value Objects, use Named Context validation:

private static Fin<Product> CreateProduct(Request request)
{
// Fields with VOs
var name = ProductName.Validate(request.Name);
var price = Money.Validate(request.Price);
// Fields without VOs: use Named Context
var note = ValidationRules.For("Note")
.NotEmpty(request.Note)
.ThenMaxLength(500);
// Merge all into a tuple - parallel validation via Apply
return (name, price, note.Value)
.Apply((n, p, noteValue) =>
Product.Create(
ProductName.Create(n).Unwrap(),
noteValue,
Money.Create(p).Unwrap()))
.As()
.ToFin();
}

Recommended: Define frequently used fields as separate ValueObjects instead of Named Context.


LINQ-based functional implementation is recommended first. It has the following advantages over traditional imperative implementation:

  • Code conciseness: Eliminates imperative if-statements and intermediate variables (50-60% code reduction)
  • Automatic error handling: Automatically returns FinT.Fail on Repository failure
  • Improved readability: Declarative LINQ queries clarify business logic
  • Maintainability: Functional chaining minimizes the impact of changes

Functional conditional checks are implemented using LanguageExt’s guard.

The key point in the following code is that guard(!exists, error) returns an immediate failure when the condition is false, expressing conditional checks declaratively within a LINQ chain without the imperative if + return pattern.

using static Functorium.Applications.Errors.ApplicationErrorType;
// Using guard in a LINQ query
from exists in _productRepository.ExistsByName(productName)
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
request.Name,
$"Product name already exists: '{request.Name}'"))
from product in _productRepository.Create(...)
select new Response(...)

guard(condition, error) returns FinT.Fail when the condition is false.

guard() is a function provided by LanguageExt that performs conditional short-circuiting in LINQ comprehension syntax. When the condition is false, it immediately fails with the specified error; when true, it returns Unit and proceeds to the next step.

// guard() in LINQ comprehension
from _ in guard(condition, Error.New("error message"))
// Equivalent imperative code
if (!condition) return Fin.Fail<T>(Error.New("error message"));

Using guard() allows expressing conditional checks declaratively within a LINQ chain without the imperative if + return pattern. Since the return type is Fin<Unit>, it is automatically lifted in a FinT<IO, T> chain.

FinT<IO, Response> usecase = ...;
// FinT<IO, Response>
// -Run()→ IO<Fin<Response>>
// -RunAsync()→ Fin<Response>
// -ToFinResponse()→ FinResponse<Response>
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();

Use the ApplicationError.For<TUsecase>() method with ApplicationErrorType sealed records:

using Functorium.Applications.Errors;
using static Functorium.Applications.Errors.ApplicationErrorType;
// Using within guard in a LINQ query
from exists in _productRepository.ExistsByName(productName)
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
request.Name,
$"Product name already exists: '{request.Name}'"))
from product in _productRepository.Create(...)
select new Response(...)
// When returning directly
return FinResponse.Fail<Response>(
ApplicationError.For<GetProductByIdQuery>(
new NotFound(),
productId.ToString(),
$"Product not found. ID: {productId}"));

The following table lists the standard Application error types provided by Functorium. Most use cases require only these types, and for special cases you can extend by inheriting from Custom.

Error TypeDescriptionUsage Example
EmptyValue is emptynew Empty()
NullValue is nullnew Null()
NotFoundCannot be foundnew NotFound()
AlreadyExistsAlready existsnew AlreadyExists()
DuplicateDuplicatenew Duplicate()
InvalidStateInvalid statenew InvalidState()
UnauthorizedNot authenticatednew Unauthorized()
ForbiddenAccess forbiddennew Forbidden()
ValidationFailedValidation failednew ValidationFailed(PropertyName: "Email")
BusinessRuleViolatedBusiness rule violatednew BusinessRuleViolated(RuleName: "MaxOrderLimit")
ConcurrencyConflictConcurrency conflictnew ConcurrencyConflict()
ResourceLockedResource lockednew ResourceLocked(ResourceName: "Order")
OperationCancelledOperation cancellednew OperationCancelled()
InsufficientPermissionInsufficient permissionnew InsufficientPermission(Permission: "Admin")
CustomCustom error (define by inheritance)public sealed record PaymentDeclined : ApplicationErrorType.Custom;new PaymentDeclined()
ApplicationErrors.{UsecaseName}.{ErrorTypeName}

Examples:

  • ApplicationErrors.CreateProductCommand.AlreadyExists
  • ApplicationErrors.GetProductByIdQuery.NotFound
  • ApplicationErrors.UpdateOrderCommand.BusinessRuleViolated
  • Type safety: Compile-time validation based on sealed records
  • Consistency: Same API pattern as DomainError and AdapterError
  • Conciseness: Can be used inline without separate class definitions
  • Standardization: Leverages standard error types from ApplicationErrorType

using LayeredArch.Domain.Entities;
using LayeredArch.Domain.ValueObjects;
using LayeredArch.Domain.Repositories;
using Functorium.Applications.Errors;
using Functorium.Applications.Linq;
using static Functorium.Applications.Errors.ApplicationErrorType;
namespace LayeredArch.Application.Usecases.Products;
/// <summary>
/// Create product Command - Apply pattern + LINQ implementation
/// </summary>
public sealed class CreateProductCommand
{
public sealed record Request(
string Name,
string Description,
decimal Price,
int StockQuantity) : ICommandRequest<Response>;
public sealed record Response(
string ProductId,
string Name,
string Description,
decimal Price,
int StockQuantity,
DateTime CreatedAt);
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(ProductName.MaxLength);
RuleFor(x => x.Description)
.MaximumLength(ProductDescription.MaxLength);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0");
RuleFor(x => x.StockQuantity)
.GreaterThanOrEqualTo(0).WithMessage("Stock quantity must be 0 or greater");
}
}
internal sealed class Usecase(
IProductRepository productRepository)
: ICommandUsecase<Request, Response>
{
private readonly IProductRepository _productRepository = productRepository;
public async ValueTask<FinResponse<Response>> Handle(Request request, CancellationToken cancellationToken)
{
// 1. Value Object validation + Apply merge
var productResult = CreateProduct(request);
if (productResult.IsFail)
{
return productResult.Match(
Succ: _ => throw new InvalidOperationException(),
Fail: error => FinResponse.Fail<Response>(error));
}
// 2. Create ProductName (for duplicate check)
var productName = ProductName.Create(request.Name).Unwrap();
// 3. Duplicate check + save via LINQ (SaveChanges + event publishing handled automatically by the pipeline)
FinT<IO, Response> usecase =
from exists in _productRepository.ExistsByName(productName)
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
request.Name,
$"Product name already exists: '{request.Name}'"))
from product in _productRepository.Create((Product)productResult)
select new Response(
product.Id.ToString(),
product.Name,
product.Description,
product.Price,
product.StockQuantity,
product.CreatedAt);
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
private static Fin<Product> CreateProduct(Request request)
{
var name = ProductName.Validate(request.Name);
var description = ProductDescription.Validate(request.Description);
var price = Money.Validate(request.Price);
var stockQuantity = Quantity.Validate(request.StockQuantity);
return (name, description, price, stockQuantity)
.Apply((n, d, p, s) =>
Product.Create(
ProductName.Create(n).Unwrap(),
ProductDescription.Create(d).Unwrap(),
Money.Create(p).Unwrap(),
Quantity.Create(s).Unwrap()))
.As()
.ToFin();
}
}
}

Core Principle: Queries do not use IRepository. Through IQueryPort-based Read Adapters, SQL is directly mapped to DTOs without Aggregate reconstruction. This rule is enforced by CqrsArchitectureRuleTests.

Ports used by Queries are defined in the Application layer (different from Domain’s IRepository):

PatternInterfacePurposeAdapter Base Class
List/SearchIQueryPort<TEntity, TDto>Search(spec, page, sort)PagedResult<TDto>DapperQueryBase<TEntity, TDto>
Single lookupIQueryPort (non-generic)Define custom methods directlyDirect implementation
Application/Usecases/Products/Ports/IProductQuery.cs
using Functorium.Applications.Queries;
using LayeredArch.Domain.AggregateRoots.Products;
namespace LayeredArch.Application.Usecases.Products.Ports;
/// <summary>
/// Product read-only adapter port.
/// Projects directly from DB to DTO without Aggregate reconstruction.
/// </summary>
public interface IProductQuery : IQueryPort<Product, ProductSummaryDto> { }
public sealed record ProductSummaryDto(
string ProductId,
string Name,
decimal Price);
Application/Usecases/Products/Ports/IProductDetailQuery.cs
using Functorium.Applications.Queries;
using LayeredArch.Domain.AggregateRoots.Products;
namespace LayeredArch.Application.Usecases.Products.Ports;
/// <summary>
/// Product single-item read-only adapter port.
/// Projects directly from DB to DTO without Aggregate reconstruction.
/// </summary>
public interface IProductDetailQuery : IQueryPort
{
FinT<IO, ProductDetailDto> GetById(ProductId id);
}
public sealed record ProductDetailDto(
string ProductId,
string Name,
string Description,
decimal Price,
DateTime CreatedAt,
Option<DateTime> UpdatedAt);

Injects a custom Port that extends IQueryPort (non-generic):

Tests.Hosts/01-SingleHost/.../GetCustomerByIdQuery.cs
using LayeredArch.Application.Usecases.Customers.Ports;
using LayeredArch.Domain.AggregateRoots.Customers;
public sealed class GetCustomerByIdQuery
{
public sealed record Request(string CustomerId) : IQueryRequest<Response>;
public sealed record Response(
string CustomerId,
string Name,
string Email,
decimal CreditLimit,
DateTime CreatedAt);
public sealed class Usecase(ICustomerDetailQuery customerDetailQuery)
: IQueryUsecase<Request, Response>
{
private readonly ICustomerDetailQuery _adapter = customerDetailQuery;
public async ValueTask<FinResponse<Response>> Handle(Request request, CancellationToken cancellationToken)
{
var customerId = CustomerId.Create(request.CustomerId);
FinT<IO, Response> usecase =
from dto in _adapter.GetById(customerId)
select new Response(
dto.CustomerId,
dto.Name,
dto.Email,
dto.CreditLimit,
dto.CreatedAt);
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
}
}

Key Point: There is no Entity → DTO conversion code. The Adapter returns DTOs directly via SQL.

Uses the Search() method of IQueryPort<TEntity, TDto> with the Specification pattern:

samples/ecommerce-ddd/.../SearchProductsQuery.cs
using Functorium.Applications.Queries;
using Functorium.Domains.Specifications;
using ECommerce.Application.Usecases.Products.Ports;
using ECommerce.Domain.AggregateRoots.Products;
using ECommerce.Domain.AggregateRoots.Products.Specifications;
public sealed class SearchProductsQuery
{
private static readonly string[] AllowedSortFields = ["Name", "Price"];
// Option<T>: optional filter field. default(Option<T>) = None → filter not applied
public sealed record Request(
Option<string> Name = default,
Option<decimal> MinPrice = default,
Option<decimal> MaxPrice = default,
int Page = 1,
int PageSize = PageRequest.DefaultPageSize,
string SortBy = "",
string SortDirection = "") : IQueryRequest<Response>;
public sealed record Response(
IReadOnlyList<ProductSummaryDto> Products,
int TotalCount,
int Page,
int PageSize,
int TotalPages,
bool HasNextPage,
bool HasPreviousPage);
// Validator: leveraging Option<T>-specific validation extension methods
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.MustSatisfyValidation(ProductName.Validate);
this.MustBePairedRange(
x => x.MinPrice,
x => x.MaxPrice,
Money.Validate,
inclusive: true);
RuleFor(x => x.SortBy).MustBeOneOf(AllowedSortFields);
RuleFor(x => x.SortDirection)
.MustBeEnumValue<Request, SortDirection>();
}
}
public sealed class Usecase(IProductQuery productQuery)
: IQueryUsecase<Request, Response>
{
private readonly IProductQuery _productQuery = productQuery;
public async ValueTask<FinResponse<Response>> Handle(Request request, CancellationToken cancellationToken)
{
var spec = BuildSpecification(request);
var pageRequest = new PageRequest(request.Page, request.PageSize);
var sortExpression = SortExpression.By(request.SortBy, SortDirection.Parse(request.SortDirection));
FinT<IO, Response> usecase =
from result in _productQuery.Search(spec, pageRequest, sortExpression)
select new Response(
result.Items,
result.TotalCount,
result.Page,
result.PageSize,
result.TotalPages,
result.HasNextPage,
result.HasPreviousPage);
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
private static Specification<Product> BuildSpecification(Request request)
{
var spec = Specification<Product>.All;
// Option<T>.Iter(): adds filter if Some, ignores if None
request.Name.Iter(name =>
spec &= new ProductNameSpec(
ProductName.Create(name).Unwrap()));
// Bind().Map().Iter(): adds range filter only when both Options are Some
request.MinPrice.Bind(min => request.MaxPrice.Map(max => (min, max)))
.Iter(t => spec &= new ProductPriceRangeSpec(
Money.Create(t.min).Unwrap(),
Money.Create(t.max).Unwrap()));
return spec;
}
}
}

Note: For details on Specification pattern definition, composition, and Repository integration, see 10-specifications.md.

Tests.Hosts/01-SingleHost/.../GetAllProductsQuery.cs
public sealed class GetAllProductsQuery
{
public sealed record Request() : IQueryRequest<Response>;
public sealed record Response(IReadOnlyList<ProductSummaryDto> Products);
public sealed class Usecase(IProductQuery productQuery)
: IQueryUsecase<Request, Response>
{
private readonly IProductQuery _productQuery = productQuery;
public async ValueTask<FinResponse<Response>> Handle(Request request, CancellationToken cancellationToken)
{
PageRequest pageRequest = new(1, int.MaxValue);
FinT<IO, Response> usecase =
from result in _productQuery.Search(Specification<Product>.All, pageRequest, SortExpression.Empty)
select new Response(result.Items);
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();
}
}
}

For domain event publishing and Event Handler implementation, see 07-domain-events.md.


When you define a Request record that implements ICommandRequest<T> or IQueryRequest<T>, CtxEnricherGenerator automatically generates IUsecaseCtxEnricher<TRequest, TResponse> implementation code that converts scalar properties of the Request/Response into ctx.* fields.

public sealed class PlaceOrderCommand
{
public sealed record Request(string CustomerId, List<OrderLine> Lines)
: ICommandRequest<Response>, ICustomerRequest;
// CustomerId → ctx.customer_id (Root: [CtxRoot] on ICustomerRequest)
// Lines → ctx.place_order_command.request.lines_count (collection → _count)
public sealed record Response(string OrderId, int LineCount, decimal TotalAmount);
// OrderId → ctx.place_order_command.response.order_id
// LineCount → ctx.place_order_command.response.line_count
// TotalAmount → ctx.place_order_command.response.total_amount
}

When [CtxRoot] is applied to an interface, properties of that interface are promoted to ctx.{field} without the Usecase prefix. When multiple Usecases implement the same interface, all activities can be searched with a single ctx.customer_id: "CUST-001" in OpenSearch:

[CtxRoot]
public interface ICustomerRequest { string CustomerId { get; } }

Excludes sensitive or unnecessary properties from Enricher generation:

public sealed record Request(
string CustomerId,
[property: CtxIgnore] string InternalToken // ctx field not generated
) : ICommandRequest<Response>;

Details: See Logging Manual §Source Generator CtxEnricher.


Transactions and Event Publishing (UsecaseTransactionPipeline)

Section titled “Transactions and Event Publishing (UsecaseTransactionPipeline)”

Transaction commits (SaveChanges) and domain event publishing for Commands are handled automatically by UsecaseTransactionPipeline. There is no need to directly inject IUnitOfWork or IDomainEventPublisher in the Usecase.

[Command Handler]
↓ Repository.Create(aggregate)
↓ → IDomainEventCollector.Track(aggregate) ← Repository calls automatically
↓ return FinResponse.Succ(response)
[UsecaseTransactionPipeline]
1. BeginTransactionAsync() ← Explicit transaction start
2. response = await next() ← Handler execution
3. if (response.IsFail) return ← On failure, rollback via transaction Dispose
4. UoW.SaveChanges() ← Save changes
5. transaction.CommitAsync() ← Transaction commit
6. PublishTrackedEvents() ← Collect, publish, clear events
7. return response ← Return original success response
// Command: inject only Repository (SaveChanges + event publishing handled by pipeline)
internal sealed class Usecase(
IProductRepository productRepository)
: ICommandUsecase<Request, Response>
// Query: inject IQueryPort-based Read Adapter (Transaction excluded at compile time via where ICommand constraint)
internal sealed class Usecase(IProductQuery productQuery)
: IQueryUsecase<Request, Response>
FinT<IO, Response> usecase =
from product in _productRepository.Create(newProduct) // Repository change
select new Response(...);
// SaveChanges + domain event publishing is handled automatically by UsecaseTransactionPipeline
[Command] Request → Metrics → Tracing → Logging → Validation → Exception → Transaction → Custom → Handler
[Query] Request → Metrics → Tracing → Logging → Validation → Caching → Exception → Custom → Handler
  • Transaction applies only to Commands via the where TRequest : ICommand<TResponse> constraint (compile-time filtering)

  • Caching applies only to Queries via the where TRequest : IQuery<TResponse> constraint (compile-time filtering)

  • Transaction is positioned after Exception → Exception pipeline handles SaveChanges exceptions

  • Transaction applies only to Commands via where ICommand<TResponse> constraint (compile-time)

  • Caching applies only to Queries via where IQuery<TResponse> constraint (compile-time)

Enable the Transaction pipeline with explicit opt-in:

services
.RegisterOpenTelemetry(configuration, AssemblyReference.Assembly)
.ConfigurePipelines(pipelines => pipelines
.UseObservability() // Enable CtxEnricher, Metrics, Tracing, Logging all at once
.UseValidation()
.UseCaching() // Caching requires separate activation
.UseException()
.UseTransaction()) // Explicitly enable Transaction
.Build();

The Transaction pipeline requires all three of IUnitOfWork, IDomainEventPublisher, and IDomainEventCollector to be registered in DI (validated by HasTransactionDependencies).

Since multiple Repositories share a single DbContext, the default isolation level is Read Committed, and concurrency conflicts are handled by EF Core’s Optimistic Concurrency ([ConcurrencyCheck] or IsConcurrencyToken()). On an Optimistic Concurrency conflict, DbUpdateConcurrencyException is thrown, and UsecaseExceptionPipeline converts it to FinResponse.Fail.

PrincipleDescription
Where SaveChanges is calledPipeline handles it automatically (not called in the Usecase)
Repository roleEntity changes + IDomainEventCollector.Track() call
Multiple Repository callsWrapped in a single SaveChanges() transaction (guaranteed by pipeline)
Event publishing timingPublished only after SaveChanges() succeeds (guaranteed by pipeline)
On event publishing failureSuccess response maintained (data already committed, only warning log recorded)
Behavior in QueriesExcluded at compile time via where ICommand<TResponse> constraint

Location: Functorium.Applications.Persistence

public interface IUnitOfWork : IObservablePort
{
FinT<IO, Unit> SaveChanges(CancellationToken cancellationToken = default);
/// <summary>
/// Starts an explicit transaction.
/// Use when immediate-execution SQL such as ExecuteDeleteAsync/ExecuteUpdateAsync
/// needs to be grouped in the same transaction as SaveChanges.
/// </summary>
Task<IUnitOfWorkTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
}

IUnitOfWorkTransaction Interface:

/// <summary>
/// Explicit transaction scope.
/// Uncommitted transactions are automatically rolled back on Dispose.
/// </summary>
public interface IUnitOfWorkTransaction : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
  • Since it inherits IObservablePort, it supports automatic Pipeline generation and observability.
  • In EF Core environments, it calls DbContext.SaveChangesAsync(); in InMemory environments, it is a no-op.
  • BeginTransactionAsync() is called automatically by UsecaseTransactionPipeline, so there is no need to use it directly in a Usecase.

Reference: For UoW Adapter implementations (EfCoreUnitOfWork, InMemoryUnitOfWork), see 13-adapters.md.


public abstract record FinResponse<A>
{
public sealed record Succ(A Value) : FinResponse<A>;
public sealed record Fail(Error Error) : FinResponse<A>;
public abstract bool IsSucc { get; }
public abstract bool IsFail { get; }
}
// Success return - return the value directly
return new Response(productId, name);
// Failure return - return Error directly
return Error.New("Product not found");
// Using FinResponse.Fail
return FinResponse.Fail<Response>(error);
Fin<Response> fin = await usecase.Run().RunAsync();
// Type conversion only
FinResponse<Response> response = fin.ToFinResponse();
// Convert while mapping the value
return fin.ToFinResponse(product => new Response(...));

public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(ProductName.MaxLength)
.WithMessage($"Product name must not exceed {ProductName.MaxLength} characters");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0");
}
}

UsecaseValidationPipeline is registered via UseValidation() in ConfigurePipelines. Validators are automatically executed before Handler execution:

services
.AddValidatorsFromAssembly(typeof(Program).Assembly)
.ConfigurePipelines(pipelines => pipelines
.UseObservability() // Enable CtxEnricher, Metrics, Tracing, Logging all at once
.UseValidation() // Explicitly enable Validation
.UseException());

FluentValidation Failure and Error Type Mapping

Section titled “FluentValidation Failure and Error Type Mapping”

FluentValidation validation failures are converted to AdapterErrorType.PipelineValidation in UsecaseValidationPipeline. This is a different error type from the Application layer’s ApplicationErrorType.ValidationFailed:

Validation LayerError TypeUsage Location
FluentValidation (Pipeline)AdapterErrorType.PipelineValidation(PropertyName)Handled automatically by UsecaseValidationPipeline
VO/Business rules (Usecase)ApplicationErrorType.ValidationFailed(PropertyName)Used manually within the Usecase

On FluentValidation failure, each ValidationFailure’s PropertyName and ErrorMessage are converted to AdapterError.For<UsecaseValidationPipeline>(new PipelineValidation(PropertyName), ...) and returned as FinResponse.Fail.

Functorium provides extension methods using C#14 extension members syntax that integrate Value Object Validate() methods into FluentValidation rules:

MethodUsage ConditionExample
MustSatisfyValidationInput type == output typeRuleFor(x => x.Price).MustSatisfyValidation(Money.ValidateAmount)
MustSatisfyValidationOf<TVO>Input type != output typeRuleFor(x => x.Name).MustSatisfyValidationOf<ProductName>(ProductName.Validate)
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
// Same input/output type: decimal → Validation<Error, decimal>
RuleFor(x => x.Price)
.MustSatisfyValidation(Money.ValidateAmount);
// Different input/output type: string → Validation<Error, ProductName>
RuleFor(x => x.Name)
.MustSatisfyValidationOf<ProductName>(ProductName.Validate);
}
}

Note: MustSatisfyValidationOf also provides a traditional extension method overload (MustSatisfyValidationOf<TRequest, TProperty, TValueObject>) for cases where C#14 extension members’ type inference limitation prevents resolving additional generic parameters in IRuleBuilderInitial.

EntityId / OneOf / PairedRange Validation Extension Methods

Section titled “EntityId / OneOf / PairedRange Validation Extension Methods”

Functorium additionally provides extension methods for frequently used validation patterns:

MethodPurposeExample
MustBeEntityId<TRequest, TEntityId>Validates that a string is a valid EntityId format (NotEmpty + TryParse combined)RuleFor(x => x.ProductId).MustBeEntityId<Request, ProductId>()
MustBeOneOf<TRequest>Validates that a value is one of the allowed string list (case-insensitive, skips null/empty)RuleFor(x => x.SortBy).MustBeOneOf<Request>(["Name", "Price"])
MustBePairedRange<TRequest, T>Validates Option<T> paired range filter (both None = pass, only one Some = fail, both Some = range validation)See example below
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
// EntityId format validation
RuleFor(x => x.ProductId)
.MustBeEntityId<Request, ProductId>();
// Allowed values list validation
RuleFor(x => x.SortBy)
.MustBeOneOf<Request>(["Name", "Price", "CreatedAt"]);
// Option<T> paired range filter validation
this.MustBePairedRange(
x => x.MinPrice,
x => x.MaxPrice,
Money.Validate);
}
}

FluentValidation extension methods for Ardalis.SmartEnum are also provided:

MethodPurpose
MustBeEnum<TRequest, TSmartEnum, TValue>Validate by SmartEnum Value
MustBeEnum<TRequest, TSmartEnum>Simplified overload for int-based SmartEnum
MustBeEnumName<TRequest, TSmartEnum, TValue>Validate by SmartEnum Name
MustBeEnumValue<TRequest, TSmartEnum>string Value SmartEnum (case-insensitive)

Implementing ICacheable on a Query Request enables caching support:

public sealed record Request(string ProductId) : IQueryRequest<Response>, ICacheable
{
public string CacheKey => $"Product:{ProductId}";
public TimeSpan? Duration => TimeSpan.FromMinutes(5);
}

UsecaseCachingPipeline applies only to Queries via the where TRequest : IQuery<TResponse> constraint and automatically caches Query Requests that implement ICacheable:

  • Uses IMemoryCache for cache hit/miss handling based on CacheKey
  • On cache hit, returns the cached response immediately without calling the Handler
  • Only caches when response.IsSucc (failure responses are not cached)
  • Default 5-minute cache when Duration is null

Compile Error When Converting Validation to Fin in Apply Pattern

Section titled “Compile Error When Converting Validation to Fin in Apply Pattern”

Cause: The result of Apply() is a Validation<Error, T> type, and using it directly where Fin<T> is expected causes a type mismatch. Solution: Use .As().ToFin() chaining to convert Validation to Fin. Example: (name, price).Apply((n, p) => Product.Create(...)).As().ToFin();

Error Handling Not Working After Repository Call in FinT<IO, T> LINQ Query

Section titled “Error Handling Not Working After Repository Call in FinT<IO, T> LINQ Query”

Cause: In LINQ from...in syntax, when a Repository returns FinT.Fail, it automatically switches to the failure track. No separate error handling code is needed. Solution: Do not handle errors with if statements inside LINQ queries. Repository failures are automatically propagated. Use the guard(condition, error) function when conditional checks are needed.

Double Commit When Calling SaveChanges() Directly in Usecase

Section titled “Double Commit When Calling SaveChanges() Directly in Usecase”

Cause: UsecaseTransactionPipeline automatically calls SaveChanges() after Handler success. Calling it directly in the Usecase results in a double commit. Solution: Do not inject IUnitOfWork in the Usecase. Both SaveChanges() and domain event publishing are handled automatically by the pipeline. Only write code up to the Repository Create()/Update() calls.


Q1. Are both FluentValidation and VO Validate() necessary?

Section titled “Q1. Are both FluentValidation and VO Validate() necessary?”

A: Yes, they each serve different purposes:

  • FluentValidation: Fast format validation at the Presentation Layer
  • VO Validate(): Domain invariant validation at the Domain Layer

Even if FluentValidation passes, VO validation can still fail (e.g., regex pattern mismatch).

Q2. When should the Apply merge pattern be used?

Section titled “Q2. When should the Apply merge pattern be used?”

A: Use it when multiple VOs need to be validated simultaneously during Entity creation. It collects and returns all validation errors at once.

A: Use it for conditional checks within LINQ queries:

from exists in _repository.ExistsByName(name)
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(), name, $"Name already exists: '{name}'"))

A: Use the ApplicationError.For<TUsecase>(ApplicationErrorType, value, message) pattern. Use inline without separate class definitions. Error codes are automatically generated in the format ApplicationErrors.{UsecaseName}.{ErrorTypeName}.

Q5. Can domain entities be returned directly in the Response?

Section titled “Q5. Can domain entities be returned directly in the Response?”

A: Not recommended. Use primitive types or DTOs:

// ✗ Not recommended - exposing domain entity
public sealed record Response(Product Product);
// ✓ Recommended - use Primitive/DTO
public sealed record Response(
string ProductId,
string Name,
decimal Price);

Q6. Should CancellationToken always be passed?

Section titled “Q6. Should CancellationToken always be passed?”

A: Yes, always pass CancellationToken to asynchronous methods. However, when using the FinT<IO, T> pattern, it is handled internally by the Repository.

Query Handler note: The Query Handler’s Handle method receives a CancellationToken cancellationToken parameter, but there is no place to pass it directly within a FinT<IO, T> LINQ chain. CancellationToken is passed by including it in the Adapter method signature when needed within IO.liftAsync blocks inside the Adapter.

Q7. Where are SaveChanges and event publishing handled?

Section titled “Q7. Where are SaveChanges and event publishing handled?”

A: UsecaseTransactionPipeline handles them automatically. There is no need to directly inject IUnitOfWork or IDomainEventPublisher in the Usecase.

  1. Usecase handles only business logic: Write code only up to Repository Create()/Update() calls.
  2. Pipeline automatically calls SaveChanges: Calls IUnitOfWork.SaveChanges() on Handler success, and does not commit on failure.
  3. Pipeline automatically publishes domain events: After SaveChanges() succeeds, automatically publishes domain events from Aggregates tracked by Repository via IDomainEventCollector.Track().

Activation: .ConfigurePipelines(pipelines => pipelines.UseObservability().UseValidation().UseException().UseTransaction())


DocumentDescription
05a-value-objects.mdValue Object implementation patterns
06b-entity-aggregate-core.mdEntity core patterns and Create pattern
07-domain-events.mdDomain event publishing and Event Handler
08a-error-system.mdError system: foundations and naming
08b-error-system-domain-app.mdError system: Domain/Application errors
08c-error-system-adapter-testing.mdError system: Adapter errors and testing
10-specifications.mdSpecification pattern (used in Use Cases)
12-ports.mdRepository interface design
15a-unit-testing.mdUsecase test writing methods

External References: