The Application layer is the business logic orchestration hub. It owns all use-case logic: input validation, state loading, mutation coordination, and event publishing. It depends only on the Contracts layer and on its own interface abstractions — it has no knowledge of EF Core, HTTP clients, or any persistence technology.
Example projects
Application services implement the domain use-cases. They are decorated with [ScopedService<TInterface>] so that the DI container registers them automatically when the host calls AddDynamicServicesUsing<T>() — no manual services.AddScoped<…>() wiring is needed.
The pattern for a mutating operation is consistent across both domains:
- Guard inputs — null/empty checks,
ThrowIfNull(). - Validate — invoke a CoreEx
Validator<T>and, either throw on failure or return aResult<T>indicating validation errors. - Load current state — retrieve via the repository interface.
- Mutate within a unit of work — call
_unitOfWork.TransactionAsync(...)which wraps the operation in a transaction and guarantees events are only dispatched on commit. - Raise events — add an
EventDatainstance to_unitOfWork.Eventsinside the same transactional scope.
// samples/src/Contoso.Products.Application/ProductService.cs
return await _unitOfWork.TransactionAsync(async () =>
{
var dr = await _repository.CreateAsync(product).ConfigureAwait(false);
return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created)));
}).ConfigureAwait(false);Shopping uses Result<T> pipelines instead of exception-based control flow:
// samples/src/Contoso.Shopping.Application/BasketService.cs
return _unitOfWork.TransactionAsync(async () =>
{
var br = await _repository.CreateAsync(aggregate).ConfigureAwait(false);
return br.ThenAs(b =>
{
var contract = BasketMapper.Map(b);
_unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created));
return contract;
});
});Each service interface lives in Application/Interfaces/ and exposes only what consumers (for example, controllers) need, hiding implementation details behind the abstraction boundary.
See also:
IUnitOfWork·EventData·[ScopedService]
Repository interfaces (Application/Repositories/) define the data contract for the Application layer without coupling it to any persistence technology. They surface domain-idiomatic operations (GetAsync, CreateAsync, QueryAsync, etc.) and return CoreEx result types (DataResult<T>, ItemsResult<T>) rather than raw EF entities.
The Shopping DDD/ROP repository uses Result<DataResult<Domain.Basket>> for Create/Update; reads still return Result<Domain.Basket>. The service maps dr.Value to a contract, gates mutation-announcement events with dr.WhereMutated(...) inside the write transaction, then returns the contract. Successful no-ops emit no mutation event even if an earlier read found the aggregate and HasChanges indicated intent to save. This applies to Create, shared Update, and Checkout; the reservation-confirmation command still follows its own successful-reservation semantics. For repositories exposing Delete, retain Result<DataResult>/DataResult and use parameterless WhereMutated(() => ...). Sequential delete idempotency does not exercise the read/delete gap; the provider may report a no-op or a concurrency failure, and the latter must propagate unchanged.
// samples/src/Contoso.Products.Application/Repositories/IProductRepository.cs
public interface IProductRepository
{
Task<Contracts.Product?> GetAsync(string id);
Task<DataResult<Contracts.Product>> CreateAsync(Contracts.Product product);
Task<ItemsResult<Contracts.ProductLite>> QueryAsync(QueryArgs? query, PagingArgs? paging);
// ...
}The Infrastructure layer implements these interfaces; the Application layer never references Infrastructure assemblies directly.
See also:
DataResult<T>·ItemsResult<T>
Validators live in Application/Validators/ and extend CoreEx's Validator<T>. They combine two complementary styles that can be used independently or together:
Declarative — property rules are composed fluently in the constructor using a rich built-in rule set (Mandatory(), MaximumLength(), IsValid(), PrecisionScale(), GreaterThanOrEqualTo(), Dictionary(), Entity(), etc.). These run synchronously and cheaply before any I/O is attempted.
Programmatic — OnValidateAsync is overridden to add behavior that cannot be expressed declaratively: repository lookups, cross-property business rules, or dynamically-constructed validators built from retrieved data. The override pattern calls context.ValidateFurtherAsync(...) to compose additional property-level rules in the same structured way.
The two phases integrate naturally: OnValidateAsync should guard with if (context.HasErrors) return; to fail fast and avoid unnecessary I/O when declarative rules have already failed.
For a patch contract using Optional<T>, .Unwrap() applies ordinary rules only when a field was supplied, and .Specified() can require field presence independently. A specified null/default is still supplied and is handled by the inner rules:
public class TravellerNotesPatchValidator : Validator<Contracts.TravellerNotesPatch, TravellerNotesPatchValidator>
{
public TravellerNotesPatchValidator() => Property(x => x.Notes).Unwrap().Mandatory();
}After validation, map the patch into the existing ordinary contract/domain object only when the field is specified; this preserves untouched fields and prevents request-only wrappers from reaching repositories or persistence models:
if (patch.Notes.IsSpecified)
traveller.Notes = patch.Notes.Value;// samples/src/Contoso.Products.Application/Validators/ProductValidator.cs
// Declarative-only — all rules expressed in the constructor.
public class ProductValidator : Validator<Contracts.Product, ProductValidator>
{
public ProductValidator()
{
Property(p => p.Sku).Mandatory().MaximumLength(50);
Property(p => p.SubCategory).Mandatory().IsValid();
Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero");
}
}// samples/src/Contoso.Products.Application/Validators/MovementRequestValidator.cs
// Declarative + programmatic — constructor rules run first, then OnValidateAsync
// performs a repository lookup and builds a second-pass validator from the result.
public class MovementRequestValidator : Validator<Contracts.MovementRequest>
{
private static readonly Validator<MovementRequestProduct> _productValidator = Validator.Create<MovementRequestProduct>()
.HasProperty(x => x.UnitOfMeasure, c => c.Mandatory().IsValid())
.HasProperty(x => x.Quantity, c => c.GreaterThanOrEqualTo(0)
.PrecisionScale(ctx => ctx.Entity.UnitOfMeasure!.Precision, ctx => ctx.Entity.UnitOfMeasure!.Scale)
.DependsOn(x => x.UnitOfMeasure));
private readonly IProductRepository _repository;
public MovementRequestValidator(IProductRepository repository)
{
_repository = repository.ThrowIfNull();
// Phase 1 — cheap declarative rules; run before any I/O.
Property(x => x.Id).Mandatory().MaximumLength(50);
Property(x => x.Products).Mandatory().Dictionary(c => c
.WithKeyValidator("Product", k => k.Mandatory().MaximumLength(50))
.WithValueValidator(v => v.Mandatory().Entity(_productValidator)));
}
protected async override Task OnValidateAsync(ValidationContext<MovementRequest> context, CancellationToken cancellationToken)
{
// Phase 2 — fail fast if phase 1 already found errors.
if (context.HasErrors) return;
// Load product data needed for business-rule validation.
var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray();
var products = await _repository.GetForReservationAsync(ids).ConfigureAwait(false);
// Dynamically build a validator using the retrieved data, then run it
// as a further structured validation pass on the same context.
var dv = Validator.Create<MovementRequestProduct>()
.HasProperty(x => x.UnitOfMeasure, c => c.Equal(
ctx => products[ctx.GetDictionaryKey<string>()].UnitOfMeasureCode));
await context.ValidateFurtherAsync(c => c
.HasProperty(x => x.Products, c => c.Dictionary(c => c
.WithKeyValidator("Product", k => k
.NotFound().WhenValue(v => !products.ContainsKey(v))
.Error("{0} is non-stocked and therefore cannot be transacted.").WhenValue(v => products[v].IsNonStocked)
.Error("{0} is not active and therefore cannot be transacted.").WhenValue(v => products[v].IsInactive))
.WithValueValidator(dv))
), cancellationToken).ConfigureAwait(false);
}
}When a property's rules depend on a condition of the dependency itself, use the predicate overload to combine the non-default/valid dependency guard with that condition:
Property(x => x.FareClass)
.Mandatory()
.DependsOn(x => x.SegmentType, segmentType => segmentType!.IsFlight);This makes FareClass mandatory only when SegmentType is valid and represents a flight. For conditions based on other entity properties, use WhenEntity(...); the DependsOn predicate is intentionally scoped to the dependent property's value.
See also:
Validator<T>·IValidatorEx
When a domain needs to interact with another domain or external service, it defines an adapter interface in Application/Adapters/. This acts as an anti-corruption layer (ACL): the Application layer depends on a domain-idiomatic abstraction, not on the remote API's schema or transport. The concrete implementation lives in Infrastructure.
Shopping's IProductAdapter is a clear example — it exposes basket-centric operations (GetAsync, ReserveInventoryAsync, CancelReservationAsync) rather than mirroring the Products API surface:
// samples/src/Contoso.Shopping.Application/Adapters/Products/IProductAdapter.cs
public interface IProductAdapter
{
Task<Result<Product>> GetAsync(string id);
Task<Result> ReserveInventoryAsync(Domain.Basket basket);
Task<Result> CancelReservationAsync(Domain.Basket basket);
}A second interface, IProductSyncAdapter, handles the event-driven data replication path — receiving published product events from the Products domain and maintaining a local read-optimised copy within Shopping's own store.
ICustomerAdapter demonstrates the alternate, real-time pattern: Shopping does not replicate Customers data, it calls the Customers API live whenever it needs it. BasketService.CreateAsync uses it (via CustomerPolicy) to validate the customerId and to default the basket's shipping address from the customer's address when the customer has one; the Infrastructure NotificationAdapter uses it to address the checkout confirmation email. The adapter returns Shopping's own Customer contract (only the properties Shopping cares about), not the Customers domain's.
// samples/src/Contoso.Shopping.Application/Adapters/Customers/ICustomerAdapter.cs
public interface ICustomerAdapter
{
Task<Result<Customer>> GetAsync(string id, CancellationToken cancellationToken = default);
}Shopping places a Mapping/ sub-folder inside the Application layer to handle the translation between the Domain aggregate and the Contract. This mapping is an Application-layer concern because it sits at the boundary between the domain model and the public surface — it is not tied to any persistence technology.
// samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs
public class BasketMapper : Mapper<Domain.Basket, Contracts.Basket, BasketMapper>
{
protected override Contracts.Basket OnMap(Domain.Basket source) => new()
{
Id = source.Id,
StatusCode = source.Status,
Pricing = new BasketPricing { Total = source.Total, ... },
Items = [.. source.Items.Select(i => BasketItemMapper.Map(i))]
};
}(see Infrastructure → Mapping).
See also:
Mapper<TSource, TDest, TSelf>
Policies (Application/Policies/) encapsulate domain-level guard logic that requires I/O (adapter or repository calls). They provide a named, independently testable home for rules that depend on external state and cannot be expressed in a validator alone (synchronous) or enforced directly in the domain model (no async I/O). A policy can be called from any point in service orchestration where the condition needs to be verified — for example, confirming that a referenced entity actually exists before proceeding with a mutation.
Classify a missing entity by the identifier's role in the operation. A missing target resource or explicitly addressed child (/bookings/{id}/travellers/{travellerId}) is NotFoundException / Result.NotFoundError (404). A missing entity referenced by a request-body field, such as travellerId in POST /bookings/{id}/travellers, is field-level validation (400 under CoreEx conventions); a missing target booking remains 404. Query parameters often filter collections: no matches normally means an empty result, while invalid query inputs follow the endpoint's validation contract.
Repositories/adapters report lookup outcomes; services/policies interpret them. Translate only expected entity-not-found outcomes for missing references and preserve other failures unchanged. A downstream 404 may instead mean an incorrect route or deployment/configuration problem. Use business errors for process/state restrictions unless specific conflict/concurrency or authorization semantics apply; error-code needs never justify reclassifying an invalid reference. Preserve deliberate idempotent-delete behavior and security-driven concealment of resource existence.
When the request contract is known, expression factories resolve the invalid field's JSON path without coupling it to the returned entity type:
// lookup is Result<Traveller>; preserve unrelated failures and successful values.
return lookup.IsNotFoundError
? Result<Traveller>.ValidationError<BookingTravellerAddRequest>(x => x.TravellerId, "Traveller was not found.")
: lookup;For nullable lookups, check null instead. In exception-style services use ValidationException.Create<Request>(...); for aggregation use MessageItem.CreateErrorMessage<Request>(...); inside validators keep using context.AddError(...) for contextual paths. Reusable policies receiving only an identifier can keep explicit caller-agreed JSON paths (or accept a resolved path); do not add a request DTO dependency solely for the shorthand. nameof(param) does not resolve JSON naming.
The following Product policy checks a body reference whose agreed JSON path is "productId"; the literal is already the resolved path, not a parameter-name-to-JSON conversion.
// samples/src/Contoso.Shopping.Application/Policies/ProductPolicy.cs
public class ProductPolicy(IProductAdapter productAdapter)
{
public Task<Result<Product>> EnsureExistsAsync(string productId) => Result
.GoAsync(() => _productAdapter.GetAsync(productId))
.OnFailure(r => r.IsNotFoundError
? Result.ValidationError(MessageItem.CreateErrorMessage("productId", "Product was not found."))
: r);
}CustomerPolicy (EnsureExistsAsync) follows the same shape for the basket's customerId, returning a customerId validation error ("Customer was not found.") when the adapter reports the expected customer-not-found outcome from the Customers lookup endpoint.