Skip to content

Application Type Design Decisions

This document analyzes the workflows defined in natural language in the business requirements from an Application architecture perspective. The first step is to identify Use Cases (Commands/Queries) from workflows, and the second step is to derive the ports each Use Case requires. Following that, it covers the design decisions for Apply pattern (parallel validation), CQRS separation, port interfaces, DTO strategy, and error types.

Analyzing the workflows from the business requirements, there are broadly two types of requests.

  • State-changing requests: Product registration/modification/deletion/restoration, stock deduction, customer creation, order creation
  • Data query requests: Product query/search, customer query, order query, inventory search

The basis for this separation can be found in the cross-workflow rules of the business requirements: “State-changing requests and data query requests are processed through separate paths.” The read path retrieves data directly in the needed format without reconstructing domain objects, so it can be optimized independently of the write path.

State-changing requests are classified as Commands, and data query requests as Queries. Each Use Case handles one workflow unit.

Use CaseTypeWorkflow
CreateProductCommandCommandProduct registration + inventory initialization
UpdateProductCommandCommandProduct modification (delete guard, product name uniqueness check)
DeleteProductCommandCommandProduct soft delete
RestoreProductCommandCommandRestore deleted product
DeductStockCommandCommandStock deduction
GetProductByIdQueryQueryProduct detail query
GetAllProductsQueryQueryAll products query
SearchProductsQueryQueryProduct search — name, price range, pagination/sorting
SearchProductsWithStockQueryQueryProduct+stock query (excluding products without stock)
SearchProductsWithOptionalStockQueryQueryProduct+stock query (including products without stock)
Use CaseTypeWorkflow
CreateCustomerCommandCommandCustomer creation (email uniqueness check)
GetCustomerByIdQueryQueryCustomer detail query
GetCustomerOrdersQueryQueryCustomer order history + product name query
SearchCustomerOrderSummaryQueryQueryPer-customer order summary search
Use CaseTypeWorkflow
CreateOrderCommandCommandOrder creation — batch product price query
CreateOrderWithCreditCheckCommandCommandOrder creation + credit limit verification
PlaceOrderCommandCommandOrder placement — credit verification + order creation + stock deduction (multi-Aggregate write)
GetOrderByIdQueryQueryOrder detail query
GetOrderWithProductsQueryQueryOrder + product name query
Use CaseTypeWorkflow
SearchInventoryQueryQueryInventory search — low stock filter, pagination/sorting

10 Commands and 10 Queries are derived, totaling 20 Use Cases. Commands change state through the domain model, and Queries retrieve data directly from the database in the needed format.

For each Use Case to communicate with the external world (database, external APIs), interfaces (ports) are needed. Following the Command/Query separation, ports are also divided into three types.

  • Write Port (Repository): Used by Command Use Cases to save and query domain objects. Defined in the Domain layer.
  • Read Port (Query Port): Used by Query Use Cases to retrieve data in the desired format. Defined in the Application layer.
  • Special Port: Dedicated port for cross-workflows. Used when data from another Aggregate is needed within a single Use Case, like batch-querying prices of multiple products during order creation.

While Write Ports guarantee domain model integrity, Read Ports focus on query performance. Since the query path does not reconstruct domain objects, complex JOINs and aggregate queries can be optimized without domain model constraints.

PortAggregatePurpose
IProductRepositoryProductProduct CRUD + uniqueness check + query including deleted
ICustomerRepositoryCustomerCustomer CRUD + uniqueness check
IOrderRepositoryOrderOrder CRUD
IInventoryRepositoryInventoryInventory CRUD + per-product query
ITagRepositoryTagTag CRUD
PortPurpose
IProductQueryProduct search + pagination
IProductDetailQuerySingle product detail query
IProductWithStockQueryProduct+stock query (products with stock only)
IProductWithOptionalStockQueryProduct+stock query (all products)
ICustomerDetailQuerySingle customer detail query
ICustomerOrdersQueryCustomer order history + product name query
ICustomerOrderSummaryQueryPer-customer order summary aggregation
IOrderDetailQuerySingle order detail query
IOrderWithProductsQueryOrder + product name query
IInventoryQueryInventory search + pagination
PortPurpose
IProductCatalogBatch query prices for multiple products (prevents per-product individual queries)
IExternalPricingServiceQuery product prices from external API

5 Write Ports, 10 Read Ports, and 2 Special Ports are derived. The detailed interface design of each port is covered in Port Interface Design.

When creating multiple Value Objects, this pattern composes validation results in parallel to collect all errors at once.

When a Use Case creates multiple Value Objects, the choice between sequential validation and parallel validation determines the user experience.

Parallel Validation Composition: tuple of Validate() -> Apply() -> final type

Section titled “Parallel Validation Composition: tuple of Validate() -> Apply() -> final type”

Each VO’s Validate() method returns Validation<Error, T>. Bundling these into a tuple and calling Apply() creates the final type on success and accumulates all errors on failure.

// CreateProductCommand.Usecase — Apply pattern
private static Fin<ProductData> CreateProductData(Request request)
{
// All fields: use 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);
// Bundle into tuple — parallel validation with Apply
return (name, description, price, stockQuantity)
.Apply((n, d, p, s) =>
new ProductData(
Product.Create(
ProductName.Create(n).ThrowIfFail(),
ProductDescription.Create(d).ThrowIfFail(),
Money.Create(p).ThrowIfFail()),
Quantity.Create(s).ThrowIfFail()))
.As()
.ToFin();
}
AspectApply (Parallel Composition)Sequential (Sequential Composition)
Error collectionAccumulates all field errorsImmediately stops at first failure
TargetVO validation (independent fields)DB queries/saves (dependent operations)
Return typeValidation<Error, T> -> .ToFin()FinT<IO, T> (from…in chain)
UX effect”Name is wrong AND price is wrong""Name is wrong” (price not checked)

Design Decision: Use Apply for VO validation, Sequential for DB operations. Since VO fields are independent of each other, parallel composition that shows all errors at once provides a better user experience. In contrast, DB operations (duplicate check -> save) depend on previous step results, so sequential execution is mandatory.

Separates Command (write) and Query (read) at the interface level.

CategoryRequest InterfaceHandler InterfacePort Type
CommandICommandRequest<TResponse>ICommandUsecase<TRequest, TResponse>Write Port (IRepository)
QueryIQueryRequest<TResponse>IQueryUsecase<TRequest, TResponse>Read Port (IQueryPort)

Key Differences:

  • Command Usecase loads Domain Aggregates, executes domain logic, and saves via Repository. Since Aggregates are reconstructed, invariants are always guaranteed.
  • Query Usecase projects directly to DTOs from the DB via Read Ports. Since Aggregates are not reconstructed, read performance is optimized.
// Command: ICommandRequest -> ICommandUsecase -> IRepository
public sealed record Request(...) : ICommandRequest<Response>;
public sealed class Usecase(...) : ICommandUsecase<Request, Response> { ... }
// Query: IQueryRequest -> IQueryUsecase -> IQueryPort
public sealed record Request(...) : IQueryRequest<Response>;
public sealed class Usecase(...) : IQueryUsecase<Request, Response> { ... }

The Application Layer defines two types of ports.

The Application layer communicates with the external world through Ports. Write Ports (Repositories) persist domain Aggregates, and Read Ports (Query Ports) project DTOs directly. Thanks to this separation, each port can be independently optimized and tested.

Write Ports (Defined in Domain Layer, inheriting IRepository<T, TId>)

Section titled “Write Ports (Defined in Domain Layer, inheriting IRepository<T, TId>)”
PortAggregateCustom Methods
IProductRepositoryProductExists(Specification), GetByIdIncludingDeleted(ProductId)
ICustomerRepositoryCustomerExists(Specification)
IOrderRepositoryOrder(basic CRUD only)
IInventoryRepositoryInventoryGetByProductId(ProductId), Exists(Specification)
ITagRepositoryTag(basic CRUD only)

Write Ports are defined in the Domain Layer. The IRepository<T, TId> base interface provides Create, GetById, Update, Delete, with custom methods added per Aggregate.

While Write Ports guarantee domain model integrity, Read Ports focus on query performance.

PortBase InterfaceReturn DTOPurpose
IProductQueryIQueryPort<Product, ProductSummaryDto>ProductSummaryDtoSpecification-based search + pagination
IProductDetailQueryIQueryPortProductDetailDtoSingle query (GetById)
IProductWithStockQueryIQueryPort<Product, ProductWithStockDto>ProductWithStockDtoProduct + Inventory JOIN
IProductWithOptionalStockQueryIQueryPort<Product, ProductWithOptionalStockDto>ProductWithOptionalStockDtoProduct + Inventory LEFT JOIN
ICustomerDetailQueryIQueryPortCustomerDetailDtoSingle query (GetById)
ICustomerOrdersQueryIQueryPortCustomerOrdersDtoCustomer -> Order -> OrderLine -> Product 4-table JOIN
ICustomerOrderSummaryQueryIQueryPort<Customer, CustomerOrderSummaryDto>CustomerOrderSummaryDtoCustomer + Order LEFT JOIN + GROUP BY aggregation
IOrderDetailQueryIQueryPortOrderDetailDtoSingle query (GetById)
IOrderWithProductsQueryIQueryPortOrderWithProductsDtoOrder + OrderLine + Product 3-table JOIN
IInventoryQueryIQueryPort<Inventory, InventorySummaryDto>InventorySummaryDtoSpecification-based search + pagination

Read Ports inherit IQueryPort (marker) or IQueryPort<TEntity, TDto> (Specification-based search). IQueryPort<TEntity, TDto> provides a Search(Specification, PageRequest, SortExpression) method by default.

PortBase InterfaceReturn TypePurpose
IProductCatalogIObservablePortSeq<(ProductId, Money)>Batch price query (N+1 prevention)
IExternalPricingServiceIObservablePortMoney, Map<string, Money>External API price query

The key decision of DTO strategy is ‘where to define them.’ Placing DTOs in separate files or shared projects makes navigation difficult and dependencies complex. Instead, nesting Request, Response, Validator, and Usecase inside a single sealed class lets you see the entire Use Case structure at a glance.

Nested record: Define Request and Response inside Command/Query class

Section titled “Nested record: Define Request and Response inside Command/Query class”

All Commands/Queries are declared as sealed class, with Request, Response, Validator, and Usecase placed as nested types inside. All types needed for a single use case are cohesive in one file.

public sealed class CreateProductCommand
{
public sealed record Request(...) : ICommandRequest<Response>;
public sealed record Response(...);
public sealed class Validator : AbstractValidator<Request> { ... }
public sealed class Usecase(...) : ICommandUsecase<Request, Response> { ... }
}

Query DTO: Read Port returns DTOs directly

Section titled “Query DTO: Read Port returns DTOs directly”

DTO record types are defined alongside the Read Port interface file. Since data is projected directly from DB to DTO without reconstructing Aggregates, domain entities and read models are separated.

// IProductQuery.cs — Interface and DTO defined in the same file
public interface IProductQuery : IQueryPort<Product, ProductSummaryDto> { }
public sealed record ProductSummaryDto(
string ProductId,
string Name,
decimal Price);

DTO Flow Difference Between Command and Query

Section titled “DTO Flow Difference Between Command and Query”
CategoryCommandQuery
InputRequest -> VO creation -> Aggregate creation/modificationRequest -> Specification assembly
OutputAggregate -> Response mappingDB -> DTO direct projection
Goes through domain modelYes (invariant guarantee)No (performance optimization)

When creating orders, prices of multiple products need to be queried. Executing individual queries per product causes the N+1 problem.

IProductCatalog: Single Round-Trip with Batch Query

Section titled “IProductCatalog: Single Round-Trip with Batch Query”
public interface IProductCatalog : IObservablePort
{
/// Batch-queries prices for multiple products.
/// Prevents N+1 round-trips with a WHERE IN query.
FinT<IO, Seq<(ProductId Id, Money Price)>> GetPricesForProducts(
IReadOnlyList<ProductId> productIds);
}

Used in: CreateOrderCommand, CreateOrderWithCreditCheckCommand, and PlaceOrderCommand.

// CreateOrderCommand.Usecase — Batch price query then convert to dictionary
var productIds = lineRequests.Select(l => l.ProductId).Distinct().ToList();
var pricesResult = await _productCatalog.GetPricesForProducts(productIds).Run().RunAsync();
var priceLookup = pricesResult.ThrowIfFail().ToDictionary(p => p.Id, p => p.Price);

Design Decision: IProductCatalog is placed in the Application Layer’s Usecases/Orders/Ports/. Unlike the Domain Layer’s IProductRepository, this port is dedicated to cross-Aggregate querying and designed to meet the Order Usecase’s requirements.

ApplicationErrorType defines type-safe errors with a sealed record hierarchy.

Error TypePurposeUsage Example
NotFoundValue not foundProduct ID query failure
AlreadyExistsValue already existsEmail/product name duplicate
ValidationFailed(PropertyName?)Validation failureVO creation failure propagation
BusinessRuleViolated(RuleName?)Business rule violationCredit limit exceeded
ConcurrencyConflictConcurrency conflictInventory RowVersion mismatch
Custom (abstract)Custom error base classDomain-specific error derivation

Auto-generates error codes in ApplicationErrors.{UsecaseName}.{ErrorName} format.

// Duplicate product name error
ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
request.Name,
$"Product name already exists: '{request.Name}'")
// -> Error code: "ApplicationErrors.CreateProductCommand.AlreadyExists"
// Product not found error
ApplicationError.For<CreateOrderCommand>(
new NotFound(),
productId.ToString(),
$"Product not found: '{productId}'")
// -> Error code: "ApplicationErrors.CreateOrderCommand.NotFound"

DomainErrorType errors (e.g., credit limit exceeded) originating from the Domain Layer propagate naturally to the Application Layer within the FinT<IO, T> chain. The FinT monad automatically short-circuits on failure, so no separate error conversion code is needed.

FinT<IO, T> is a monad transformer that composes IO effects with Fin<T> results. LINQ from...in syntax sequentially chains asynchronous operations.

Composing Operations with the from…in Pattern

Section titled “Composing Operations with the from…in Pattern”
// CreateProductCommand.Usecase — Duplicate check -> Save -> Inventory creation
FinT<IO, Response> usecase =
from exists in _productRepository.Exists(new ProductNameUniqueSpec(productName))
from _ in guard(!exists, ApplicationError.For<CreateProductCommand>(
new AlreadyExists(), request.Name,
$"Product name already exists: '{request.Name}'"))
from createdProduct in _productRepository.Create(product)
from createdInventory in _inventoryRepository.Create(
Inventory.Create(createdProduct.Id, stockQuantity))
select new Response(...);
Fin<Response> response = await usecase.Run().RunAsync();
return response.ToFinResponse();

guard(condition, error) short-circuits the chain to failure when the condition is false. Used when validating business rules based on Repository call results.

// Failure on duplicate existence
from exists in _customerRepository.Exists(new CustomerEmailSpec(email))
from _ in guard(!exists, ApplicationError.For<CreateCustomerCommand>(
new AlreadyExists(), request.Email,
$"Email already exists: '{request.Email}'"))
// CreateOrderWithCreditCheckCommand.Usecase — Customer query -> Credit verification -> Save
FinT<IO, Response> usecase =
from customer in _customerRepository.GetById(customerId)
from _ in _creditCheckService.ValidateCreditLimit(customer, newOrder.TotalAmount)
from saved in _orderRepository.Create(newOrder)
select new Response(...);

Repository calls (_customerRepository.GetById) and Domain Service calls (_creditCheckService.ValidateCreditLimit) compose naturally in the same from...in chain. Since the Domain Service returns Fin<Unit>, the chain automatically short-circuits on validation failure.

When atomically saving multiple Aggregates in a single transaction, FinT chains are directly composed with Bind and Map. PlaceOrderCommand bundles Order creation and Inventory updates into a single IO effect, implementing the UoW (Unit of Work) pattern.

// PlaceOrderCommand.Usecase — Multi-Aggregate write
FinT<IO, Response> usecase =
_orderRepository.Create(order).Bind(saved =>
_inventoryRepository.UpdateRange(deductedInventories).Map(updatedInventories =>
new Response(
saved.Id.ToString(),
...,
updatedInventories.Select(inv => new DeductedStockInfo(
inv.ProductId.ToString(),
inv.StockQuantity)),
saved.CreatedAt)));

CreateProductCommand also creates Product and Inventory together, but that is a simple pair creation. PlaceOrderCommand is distinguished as a read -> validate -> multi-write business transaction. Pre-validation (stock deduction, credit check) is performed imperatively, and only the final writes remain in the FinT chain, making the UoW boundary explicit.

The Command path passes through FluentValidation syntax validation -> Apply pattern domain validation -> FinT LINQ chaining (guard, Repository) -> Response conversion. If any stage fails, the error is immediately propagated.

The Query path, unlike Commands, does not go through domain Aggregates. Specifications are assembled and passed to Read Ports, where DTOs are projected directly from the DB.

Apply, CQRS, Port, and FinT are not independent patterns but are connected as a single pipeline. Apply validates input, CQRS separates read/write paths, Ports abstract external dependencies, and FinT manages the success/failure of the entire flow. Thanks to this combination, Use Case code focuses only on ‘what to do,’ while ‘how to handle errors’ is delegated to the type system.

How this type design is implemented using C# and Functorium building blocks is covered in the code design.