Skip to content

Error System — Domain/Application Errors

This document covers error definitions and test patterns for the Domain/Application/Event layers. For basic principles and naming rules of error handling, refer to 08a-error-system.md. For Adapter errors, Custom errors, testing best practices, and per-layer checklists, refer to 08c-error-system-adapter-testing.md.

08a-error-system.md covered the fundamentals and naming rules of the error system. This document examines Domain and Application layer error definitions, factory method usage, and test assertion patterns in detail.

Each layer’s error factory (DomainError.For, ApplicationError.For, EventError.For) explicitly identifies the error source in the type system, making it immediately clear which layer the problem originated from based on the error code alone.

// Domain error
DomainError.For<Email>(new Empty(), value, "Email cannot be empty");
DomainError.For<Age, int>(new Negative(), value, "Age cannot be negative");
// Application error
ApplicationError.For<CreateProductCommand>(new AlreadyExists(), code, "Already exists");
// Event error
EventError.For<DomainEventPublisher>(new PublishFailed(), eventType, "Failed to publish event");
// Test assertions
result.ShouldBeDomainError<Email, Email>(new DomainErrorType.Empty());
fin.ShouldBeApplicationError<GetProductQuery, Product>(new ApplicationErrorType.NotFound());
  1. Determine which layer the error originates from (Domain / Application / Event)
  2. Select a standard error type or define a Custom sealed record
  3. Create the error using the layer factory (DomainError.For, ApplicationError.For, EventError.For)
  4. Write tests using assertion methods from the Functorium.Testing.Assertions.Errors namespace
LayerFactoryError Code PrefixWhen to Use
DomainDomainErrorDomainErrors.VO validation, Entity invariants, Aggregate rules
ApplicationApplicationErrorApplicationErrors.Usecase business logic, authorization/authentication
EventEventErrorApplicationErrors.Event publishing/handler failures

We first examine Domain error creation and test patterns, then move on to Application errors and Event errors.


Use DomainError.For<T>() to create errors for Value Object validation or Entity invariant violations. The examples below show the overload differences based on the number of type parameters.

using Functorium.Domains.Errors;
using static Functorium.Domains.Errors.DomainErrorType;
// Basic usage - return directly via implicit conversion
public Fin<Email> Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return DomainError.For<Email>(
new Empty(),
currentValue: value ?? "",
message: "Email cannot be empty");
return new Email(value);
}
// Generic value type
public Fin<Age> Create(int value)
{
if (value < 0)
return DomainError.For<Age, int>(
new Negative(),
currentValue: value,
message: "Age cannot be negative");
return new Age(value);
}
// Two values included
// Error type definition: public sealed record InvalidRange : DomainErrorType.Custom;
public Fin<DateRange> Create(DateTime start, DateTime end)
{
if (start >= end)
return DomainError.For<DateRange, DateTime, DateTime>(
new InvalidRange(),
start, end,
message: "Start date must be before end date");
return new DateRange(start, end);
}
// Three values included
// Error type definition: public sealed record InvalidTriangle : DomainErrorType.Custom;
public Fin<Triangle> Create(double a, double b, double c)
{
if (a + b <= c || b + c <= a || c + a <= b)
return DomainError.For<Triangle, double, double, double>(
new InvalidTriangle(),
a, b, c,
message: "Cannot form a valid triangle");
return new Triangle(a, b, c);
}
public sealed class Product : AggregateRoot<ProductId>
{
public sealed record InsufficientStock : DomainErrorType.Custom;
public Fin<Unit> DeductStock(Quantity quantity)
{
if ((int)quantity > (int)StockQuantity)
return DomainError.For<Product, int>(
new InsufficientStock(),
currentValue: (int)StockQuantity,
message: $"Insufficient stock. Current: {(int)StockQuantity}, Requested: {(int)quantity}");
StockQuantity = Quantity.Create((int)StockQuantity - (int)quantity).ThrowIfFail();
AddDomainEvent(new StockDeductedEvent(Id, quantity));
return unit;
}
}

DomainErrorType Category Structure and Complete List

Section titled “DomainErrorType Category Structure and Complete List”

The following table categorizes DomainErrorType by category and lists the files where each error type is defined.

CategoryFileDescription
PresenceDomainErrorType.Presence.csValue existence validation
LengthDomainErrorType.Length.csString/collection length validation
FormatDomainErrorType.Format.csFormat and case validation
DateTimeDomainErrorType.DateTime.csDate validation
NumericDomainErrorType.Numeric.csNumeric value/range validation
RangeDomainErrorType.Range.csmin/max pair validation
ExistenceDomainErrorType.Existence.csExistence validation
CustomDomainErrorType.Custom.csCustom errors

Presence (Value Existence Validation) - R1

Section titled “Presence (Value Existence Validation) - R1”
Error TypeDescriptionUsage Example
EmptyIs empty (null, empty string, empty collection)new Empty()
NullIs nullnew Null()

Length (String/Collection Length Validation) - R2, R6

Section titled “Length (String/Collection Length Validation) - R2, R6”
Error TypeDescriptionUsage Example
TooShortBelow minimum lengthnew TooShort(MinLength: 8)
TooLongExceeds maximum lengthnew TooLong(MaxLength: 100)
WrongLengthExact length mismatchnew WrongLength(Expected: 10)
Error TypeDescriptionUsage Example
InvalidFormatFormat mismatchnew InvalidFormat(Pattern: @"^\d{3}-\d{4}$")
NotUpperCaseNot uppercasenew NotUpperCase()
NotLowerCaseNot lowercasenew NotLowerCase()
Error TypeDescriptionUsage Example
DefaultDateDate is default value (DateTime.MinValue)new DefaultDate()
NotInPastDate should be in past but is in futurenew NotInPast()
NotInFutureDate should be in future but is in pastnew NotInFuture()
TooLateDate is later than boundary (should be before)new TooLate(Boundary: "2025-12-31")
TooEarlyDate is earlier than boundary (should be after)new TooEarly(Boundary: "2020-01-01")
Error TypeDescriptionUsage Example
ZeroIs zeronew Zero()
NegativeIs negativenew Negative()
NotPositiveNot positive (includes 0)new NotPositive()
OutOfRangeOut of rangenew OutOfRange(Min: "1", Max: "100")
BelowMinimumBelow minimumnew BelowMinimum(Minimum: "0")
AboveMaximumExceeds maximumnew AboveMaximum(Maximum: "1000")
Error TypeDescriptionUsage Example
RangeInvertedRange is inverted (min is greater than max)new RangeInverted(Min: "10", Max: "1")
RangeEmptyRange is empty (min == max, strict range)new RangeEmpty(Value: "5")

Existence (Existence Validation) - R1, R3, R4

Section titled “Existence (Existence Validation) - R1, R3, R4”
Error TypeDescriptionUsage Example
NotFoundNot foundnew NotFound()
AlreadyExistsAlready existsnew AlreadyExists()
DuplicateDuplicatednew Duplicate()
MismatchValue mismatchnew Mismatch()
Error TypeDescriptionUsage Example
CustomDomain-specific error (abstract)sealed record AlreadyShipped : DomainErrorType.Custom; -> new AlreadyShipped()
public sealed class Email : SimpleValueObject<string>
{
private static readonly Regex EmailPattern = new(@"^[^@]+@[^@]+\.[^@]+$");
private const int MaxLength = 254;
private Email(string value) : base(value) { }
public static Fin<Email> Create(string? value) =>
CreateFromValidation(Validate(value), v => new Email(v));
public static Validation<Error, string> Validate(string? value) =>
ValidationRules<Email>.NotEmpty(value ?? "")
.ThenMatches(EmailPattern)
.ThenMaxLength(MaxLength);
}

Test assertion namespace:

using Functorium.Testing.Assertions.Errors;

How ShouldBeDomainError assertion’s type parameter specifies the error source type.

// Basic error type verification
[Fact]
public void ShouldBeDomainError_WhenValueIsEmpty()
{
// Arrange
var error = DomainError.For<Email>(
new DomainErrorType.Empty(),
currentValue: "",
message: "Email cannot be empty");
// Act & Assert
error.ShouldBeDomainError<Email>(new DomainErrorType.Empty());
}
// Verification including current value
[Fact]
public void ShouldBeDomainError_WithValue_WhenValueIsNegative()
{
// Arrange
var error = DomainError.For<Age, int>(
new DomainErrorType.Negative(),
currentValue: -5,
message: "Age cannot be negative");
// Act & Assert
error.ShouldBeDomainError<Age, int>(
new DomainErrorType.Negative(),
expectedCurrentValue: -5);
}
// Verification including two values
// Error type definition: public sealed record InvalidRange : DomainErrorType.Custom;
[Fact]
public void ShouldBeDomainError_WithTwoValues_WhenRangeIsInvalid()
{
// Arrange
var startDate = new DateTime(2024, 12, 31);
var endDate = new DateTime(2024, 1, 1);
var error = DomainError.For<DateRange, DateTime, DateTime>(
new InvalidRange(),
startDate,
endDate,
message: "Start date must be before end date");
// Act & Assert
error.ShouldBeDomainError<DateRange, DateTime, DateTime>(
new InvalidRange(),
expectedValue1: startDate,
expectedValue2: endDate);
}
// Verification including three values
// Error type definition: public sealed record InvalidTriangle : DomainErrorType.Custom;
[Fact]
public void ShouldBeDomainError_WithThreeValues()
{
// Arrange
var error = DomainError.For<Triangle, double, double, double>(
new InvalidTriangle(),
1.0, 2.0, 10.0,
message: "Invalid triangle sides");
// Act & Assert
error.ShouldBeDomainError<Triangle, double, double, double>(
new InvalidTriangle(),
expectedValue1: 1.0,
expectedValue2: 2.0,
expectedValue3: 10.0);
}
[Fact]
public void Fin_ShouldBeDomainError_WhenCreationFails()
{
// Arrange
Fin<Email> fin = DomainError.For<Email>(
new DomainErrorType.InvalidFormat(),
currentValue: "invalid-email",
message: "Invalid email format");
// Act & Assert
// ShouldBeDomainError<TErrorSource, TFin>: TErrorSource = error source type, TFin = T of Fin<T>
fin.ShouldBeDomainError<Email, Email>(new DomainErrorType.InvalidFormat());
}
[Fact]
public void Fin_ShouldBeDomainError_WithValue()
{
// Arrange
Fin<Age> fin = DomainError.For<Age, int>(
new DomainErrorType.Negative(),
currentValue: -5,
message: "Age cannot be negative");
// Act & Assert
fin.ShouldBeDomainError<Age, Age, int>(
new DomainErrorType.Negative(),
expectedCurrentValue: -5);
}
// Verify whether a specific error is included
[Fact]
public void Validation_ShouldHaveDomainError()
{
// Arrange
Validation<Error, Address> validation = Fail<Error, Address>(
DomainError.For<Street>(
new DomainErrorType.Empty(),
currentValue: "",
message: "Street cannot be empty"));
// Act & Assert
validation.ShouldHaveDomainError<Street, Address>(new DomainErrorType.Empty());
}
// Verify exactly one error is included
[Fact]
public void Validation_ShouldHaveOnlyDomainError()
{
// Arrange
Validation<Error, PostalCode> validation = Fail<Error, PostalCode>(
DomainError.For<PostalCode>(
new DomainErrorType.InvalidFormat(),
currentValue: "invalid",
message: "Invalid postal code format"));
// Act & Assert
validation.ShouldHaveOnlyDomainError<PostalCode, PostalCode>(
new DomainErrorType.InvalidFormat());
}
// Verify all multiple errors are included
[Fact]
public void Validation_ShouldHaveDomainErrors_WhenMultipleErrorsExist()
{
// Arrange
var error1 = DomainError.For<Password>(
new DomainErrorType.TooShort(MinLength: 8),
currentValue: "abc",
message: "Password too short");
var error2 = DomainError.For<Password>(
new DomainErrorType.NotUpperCase(),
currentValue: "abc",
message: "Password must contain uppercase");
Validation<Error, Password> validation = Fail<Error, Password>(Error.Many(error1, error2));
// Act & Assert
validation.ShouldHaveDomainErrors<Password, Password>(
new DomainErrorType.TooShort(MinLength: 8),
new DomainErrorType.NotUpperCase());
}
// Verification including current value
[Fact]
public void Validation_ShouldHaveDomainError_WithValue()
{
// Arrange
Validation<Error, Quantity> validation = Fail<Error, Quantity>(
DomainError.For<Quantity, int>(
new DomainErrorType.Negative(),
currentValue: -10,
message: "Quantity cannot be negative"));
// Act & Assert
validation.ShouldHaveDomainError<Quantity, Quantity, int>(
new DomainErrorType.Negative(),
expectedCurrentValue: -10);
}

Now that we have confirmed Domain error creation and test patterns, let’s move on to Application errors used at the Usecase level.


using Functorium.Applications.Errors;
using static Functorium.Applications.Errors.ApplicationErrorType;
// Basic usage - return directly via implicit conversion
if (await _repository.ExistsAsync(command.ProductCode))
{
return ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
command.ProductCode,
"Product code already exists");
}
// Generic value type
return ApplicationError.For<UpdateOrderCommand, Guid>(
new NotFound(),
orderId,
"Order not found");
// Including two values
return ApplicationError.For<TransferCommand, decimal, decimal>(
new BusinessRuleViolated("InsufficientBalance"),
balance, amount,
"Insufficient balance");

The following table categorizes Application error types by category.

Error TypeDescriptionUsage Example
EmptyIs emptynew Empty()
NullIs nullnew Null()
NotFoundNot foundnew NotFound()
AlreadyExistsAlready existsnew AlreadyExists()
DuplicateDuplicatednew Duplicate()
InvalidStateInvalid statenew InvalidState()
Error TypeDescriptionUsage Example
UnauthorizedNot authenticatednew Unauthorized()
ForbiddenAccess forbiddennew Forbidden()
Error TypeDescriptionUsage Example
ValidationFailedValidation failednew ValidationFailed(PropertyName: "Quantity")
BusinessRuleViolatedBusiness rule violatednew BusinessRuleViolated(RuleName: "MaxOrderLimit")
ConcurrencyConflictConcurrency conflictnew ConcurrencyConflict()
ResourceLockedResource lockednew ResourceLocked(ResourceName: "Order")
OperationCancelledOperation cancellednew OperationCancelled()
InsufficientPermissionInsufficient permissionnew InsufficientPermission(Permission: "Admin")
Error TypeDescriptionUsage Example
CustomApplication-specific error (abstract)sealed record PaymentDeclined : ApplicationErrorType.Custom;new PaymentDeclined()

This shows both the pattern of using ApplicationError.For in LINQ query guard clauses and the pattern of returning directly.

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 Usecase(IProductRepository productRepository)
: ICommandUsecase<Request, Response>
{
public async ValueTask<FinResponse<Response>> Handle(Request request, ...)
{
// Used with guard in LINQ query
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(...)
select new Response(...);
// Direct return (implicit conversion)
return ApplicationError.For<CreateProductCommand>(
new NotFound(),
productId.ToString(),
$"Product not found. ID: {productId}");
}
}
}

Error code format:

ApplicationErrors.{UsecaseName}.{ErrorTypeName}

Examples:

  • ApplicationErrors.CreateProductCommand.AlreadyExists
  • ApplicationErrors.UpdateProductCommand.NotFound
  • ApplicationErrors.DeleteOrderCommand.BusinessRuleViolated

Usecase usage example:

public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, FinResponse<ProductId>>
{
public async ValueTask<FinResponse<ProductId>> Handle(
CreateProductCommand command,
CancellationToken cancellationToken)
{
// Duplicate check - return directly via implicit conversion
if (await _repository.ExistsAsync(command.ProductCode))
{
return ApplicationError.For<CreateProductCommand>(
new AlreadyExists(),
command.ProductCode,
"Product code already exists");
}
// Business rule validation
if (command.Price <= 0)
{
return ApplicationError.For<CreateProductCommand, decimal>(
new BusinessRuleViolated("PositivePrice"),
command.Price,
"Price must be positive");
}
// Success handling
var product = Product.Create(command.ProductCode, command.Name, command.Price);
await _repository.AddAsync(product);
return product.Id;
}
}

Test assertion namespace:

using Functorium.Testing.Assertions.Errors;
// Basic error type verification
[Fact]
public void ShouldBeApplicationError_WhenProductNotFound()
{
// Arrange
var error = ApplicationError.For<GetProductQuery>(
new ApplicationErrorType.NotFound(),
currentValue: "PROD-001",
message: "Product not found");
// Act & Assert
error.ShouldBeApplicationError<GetProductQuery>(new ApplicationErrorType.NotFound());
}
// Verification including current value
[Fact]
public void ShouldBeApplicationError_WithValue_WhenDuplicate()
{
// Arrange
var productId = Guid.NewGuid();
var error = ApplicationError.For<CreateProductCommand, Guid>(
new ApplicationErrorType.AlreadyExists(),
currentValue: productId,
message: "Product already exists");
// Act & Assert
error.ShouldBeApplicationError<CreateProductCommand, Guid>(
new ApplicationErrorType.AlreadyExists(),
expectedCurrentValue: productId);
}
// Verification including two values
[Fact]
public void ShouldBeApplicationError_WithTwoValues_WhenBusinessRuleViolated()
{
// Arrange
var error = ApplicationError.For<TransferCommand, decimal, decimal>(
new ApplicationErrorType.BusinessRuleViolated("InsufficientBalance"),
100m,
500m,
message: "Insufficient balance for transfer");
// Act & Assert
error.ShouldBeApplicationError<TransferCommand, decimal, decimal>(
new ApplicationErrorType.BusinessRuleViolated("InsufficientBalance"),
expectedValue1: 100m,
expectedValue2: 500m);
}
[Fact]
public void Fin_ShouldBeApplicationError_WhenQueryFails()
{
// Arrange
Fin<Product> fin = ApplicationError.For<GetProductQuery>(
new ApplicationErrorType.NotFound(),
currentValue: "PROD-001",
message: "Product not found");
// Act & Assert
fin.ShouldBeApplicationError<GetProductQuery, Product>(
new ApplicationErrorType.NotFound());
}
[Fact]
public void Fin_ShouldBeApplicationError_WithValue()
{
// Arrange
var orderId = Guid.NewGuid();
Fin<Order> fin = ApplicationError.For<CancelOrderCommand, Guid>(
new ApplicationErrorType.InvalidState(),
currentValue: orderId,
message: "Cannot cancel shipped order");
// Act & Assert
fin.ShouldBeApplicationError<CancelOrderCommand, Order, Guid>(
new ApplicationErrorType.InvalidState(),
expectedCurrentValue: orderId);
}
[Fact]
public void Validation_ShouldHaveApplicationError()
{
// Arrange
Validation<Error, ProductId> validation = Fail<Error, ProductId>(
ApplicationError.For<CreateProductCommand>(
new ApplicationErrorType.AlreadyExists(),
currentValue: "PROD-001",
message: "Product already exists"));
// Act & Assert
validation.ShouldHaveApplicationError<CreateProductCommand, ProductId>(
new ApplicationErrorType.AlreadyExists());
}
[Fact]
public void Validation_ShouldHaveOnlyApplicationError()
{
// Arrange
Validation<Error, Unit> validation = Fail<Error, Unit>(
ApplicationError.For<DeleteOrderCommand>(
new ApplicationErrorType.Forbidden(),
currentValue: "ORDER-001",
message: "Cannot delete this order"));
// Act & Assert
validation.ShouldHaveOnlyApplicationError<DeleteOrderCommand, Unit>(
new ApplicationErrorType.Forbidden());
}
[Fact]
public void Validation_ShouldHaveApplicationErrors()
{
// Arrange
var error1 = ApplicationError.For<UpdateUserCommand>(
new ApplicationErrorType.ValidationFailed("Email"),
currentValue: "",
message: "Email is required");
var error2 = ApplicationError.For<UpdateUserCommand>(
new ApplicationErrorType.ValidationFailed("Name"),
currentValue: "",
message: "Name is required");
Validation<Error, Unit> validation = Fail<Error, Unit>(Error.Many(error1, error2));
// Act & Assert
validation.ShouldHaveApplicationErrors<UpdateUserCommand, Unit>(
new ApplicationErrorType.ValidationFailed("Email"),
new ApplicationErrorType.ValidationFailed("Name"));
}

Having examined the definition and testing of Application errors, let’s now look at Event errors that represent internal failures in the event system.


using Functorium.Applications.Errors;
using static Functorium.Applications.Errors.EventErrorType;
// Basic usage - event publishing failure
EventError.For<DomainEventPublisher>(
new PublishFailed(),
eventType,
"Failed to publish event");
// Generic value type
EventError.For<ObservableDomainEventPublisher, Guid>(
new HandlerFailed(),
eventId,
"Event handler threw exception");
// Exception wrapping (default PublishFailed type)
EventError.FromException<DomainEventPublisher>(exception);
// Exception wrapping (specifying a specific error type)
EventError.FromException<DomainEventPublisher>(
new HandlerFailed(),
exception);
Error TypeDescriptionUsage Example
PublishFailedEvent publishing failurenew PublishFailed()
HandlerFailedEvent handler execution failurenew HandlerFailed()
InvalidEventTypeInvalid event typenew InvalidEventType()
PublishCancelledEvent publishing cancellednew PublishCancelled()
CustomEvent-specific custom error (abstract)sealed record RetryExhausted : EventErrorType.Custom;new RetryExhausted()

EventError uses the Application layer prefix:

ApplicationErrors.{PublisherName}.{ErrorTypeName}

Examples:

  • ApplicationErrors.DomainEventPublisher.PublishFailed
  • ApplicationErrors.ObservableDomainEventPublisher.HandlerFailed
  • ApplicationErrors.DomainEventPublisher.InvalidEventType

ShouldBeDomainError Assertion Fails in Tests

Section titled “ShouldBeDomainError Assertion Fails in Tests”

Cause: The error type parameters do not match. For example, if you create with TooShort(MinLength: 8) but verify with new TooShort(MinLength: 3), the assertion fails. Resolution: Error type parameters must match exactly. Since they are sealed record-based, all fields are included in equality comparison.

Custom Error Not Recognized by ShouldBeDomainError

Section titled “Custom Error Not Recognized by ShouldBeDomainError”

Cause: The Custom error may be defined in the wrong location, or it may not inherit from DomainErrorType.Custom. Resolution: Custom errors must inherit from the corresponding layer’s Custom abstract record. Example: public sealed record InsufficientStock : DomainErrorType.Custom;


Q1. What is the criterion for distinguishing Domain errors from Application errors?

Section titled “Q1. What is the criterion for distinguishing Domain errors from Application errors?”

Domain errors are used for invariant violations within the domain model (VO validation failures, Entity state rule violations). Application errors are used for Usecase-level business logic (duplicate checks, authorization checks, resource lookup failures). The criterion is the location (layer) of the code where the error occurs.

Use it for domain event publishing failures (PublishFailed, PublishCancelled) or event handler execution failures (HandlerFailed). It is a dedicated error type for expressing internal failures of the event system. The error code prefix uses ApplicationErrors..

Q3. What information should be included as the current value (currentValue) in errors?

Section titled “Q3. What information should be included as the current value (currentValue) in errors?”

Include information that helps with debugging. Typically this includes the failed validation input value (id.ToString(), request.Name), current state values (Status.ToString(), (int)StockQuantity), etc. Do not include sensitive information (passwords, tokens).