ting a resilient error-handling strategy in AI-assisted workflows requires three coordinated layers: explicit outcome types, boundary mapping, and persistent AI context enforcement. The following implementation demonstrates a production-ready pattern using C# and MediatR, with all naming and structure redesigned for clarity.
Step 1: Define the Explicit Outcome Contract
Avoid overcomplicating the result type. A flat, discriminated structure works best for AI generation and human readability.
public sealed class OperationResult<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public ErrorDetail? Error { get; }
private OperationResult(T value) => (IsSuccess, Value) = (true, value);
private OperationResult(ErrorDetail error) => (IsFailure, Error) = (true, error);
public static OperationResult<T> Success(T value) => new(value);
public static OperationResult<T> Failure(ErrorDetail error) => new(error);
}
public sealed record ErrorDetail(string Code, string Message);
Architectural Rationale: This structure avoids generic overloading, keeps serialization predictable, and forces explicit error construction. AI models generate this pattern consistently because the factory methods (Success, Failure) provide clear semantic anchors.
Step 2: Structure the Domain Handler
Handlers must return the outcome type and never throw for business violations. Infrastructure calls are wrapped to convert exceptions into outcome failures.
public sealed class ReserveStockCommand : IRequest<OperationResult<InventoryReservation>>
{
public string ProductSku { get; init; } = string.Empty;
public int Quantity { get; init; }
}
public sealed class StockReservationHandler : IRequestHandler<ReserveStockCommand, OperationResult<InventoryReservation>>
{
private readonly IStockRepository _repository;
public StockReservationHandler(IStockRepository repository) => _repository = repository;
public async Task<OperationResult<InventoryReservation>> Handle(ReserveStockCommand command, CancellationToken ct)
{
var existing = await _repository.GetBySkuAsync(command.ProductSku, ct);
if (existing is not null)
return OperationResult<InventoryReservation>.Failure(
new ErrorDetail("DUPLICATE_SKU", $"Reservation already exists for {command.ProductSku}"));
var reservation = InventoryReservation.Create(command.ProductSku, command.Quantity);
if (reservation.IsInvalid)
return OperationResult<InventoryReservation>.Failure(reservation.ValidationError);
await _repository.InsertAsync(reservation, ct);
return OperationResult<InventoryReservation>.Success(reservation);
}
}
Architectural Rationale: Business validation returns explicit failures. Domain creation validates internally and exposes errors. The handler never throws. This keeps the control flow linear and testable. AI assistants generate this pattern reliably when the return type is explicitly declared in the interface.
Step 3: Implement Boundary Mapping at the API Layer
Controllers and minimal API endpoints must remain thin. They translate outcomes to HTTP responses without containing business logic.
app.MapPost("/api/inventory/reserve", async (ReserveStockCommand cmd, ISender sender, CancellationToken ct) =>
{
var outcome = await sender.Send(cmd, ct);
return outcome.IsSuccess
? Results.Created($"/api/inventory/{outcome.Value!.Id}", outcome.Value)
: Results.Problem(
statusCode: outcome.Error!.Code switch
{
"DUPLICATE_SKU" => StatusCodes.Status409Conflict,
"INVALID_QUANTITY" => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
},
detail: outcome.Error.Message);
});
Architectural Rationale: Mapping occurs at the edge. This centralizes HTTP semantics, prevents controller bloat, and ensures AI-generated endpoints follow a predictable template. The switch expression on error codes makes status mapping explicit and auditable.
Step 4: Enforce AI Context Persistence
One-time prompts decay. Persistent rules must be scoped to application-layer files and loaded automatically.
Create a .cursorrules or .mdc file in the Application/ directory:
## Error Handling Contract
- All handlers return OperationResult<T> or OperationResult.
- Business failures use OperationResult.Failure(ErrorDetail). Never throw for domain violations.
- Infrastructure exceptions (timeouts, DB failures) are caught and converted to Failure("INFRA_ERROR", message).
- API endpoints map outcomes to HTTP status codes. No try/catch in controllers.
- Validators run before handlers. Validation failures become OperationResult.Failure before execution.
Architectural Rationale: Scoped rules reload when the relevant layer opens. This eliminates context decay and ensures AI assistants respect the error contract regardless of session history.
Pitfall Guide
1. Conflating Transient and Business Failures
Explanation: Throwing DbUpdateException alongside DuplicateKeyError in the same stream makes it impossible to distinguish retryable infrastructure faults from non-retryable business violations.
Fix: Wrap infrastructure calls in explicit try/catch blocks. Convert caught exceptions to OperationResult.Failure("INFRA_ERROR", ex.Message). Reserve throw only for unrecoverable programmer errors (null reference in internal logic, invalid state transitions).
2. Returning Null for Missing Resources
Explanation: AI defaults to return null; when a record isn't found. This breaks explicit contracts and forces callers to perform null checks.
Fix: Always return OperationResult.Failure(new ErrorDetail("NOT_FOUND", "Resource does not exist")). Document this rule in scoped AI rules and enforce it via static analysis or code review checklists.
3. Ignoring AI Context Decay
Explanation: Pasting error-handling instructions into a chat window works for one file. After two prompts, the model reverts to training defaults.
Fix: Use scoped rule files (.cursorrules, .mdc, or .github/copilot-instructions.md). Reference an Architecture Decision Record (ADR) in the project root. Initialize sessions with a LEARNING_LOG.md that the AI reads before generating code.
4. Overcomplicating the Outcome Type
Explanation: Adding multiple generic parameters, custom fluent methods, or inheritance hierarchies to the result type confuses AI generation and increases cognitive load.
Fix: Keep the outcome flat. Use two factory methods (Success, Failure), two boolean flags (IsSuccess, IsFailure), and a simple error record. Avoid chaining or builder patterns unless strictly required by domain complexity.
5. Skipping Boundary Mapping
Explanation: Handling outcomes inside controllers or mixing HTTP logic with domain logic creates inconsistent status codes and bloated endpoints.
Fix: Centralize mapping in a dedicated middleware, extension method, or minimal API template. Ensure every endpoint follows the same pattern: await sender.Send() -> outcome.Match() -> HTTP response.
6. Testing the Wrong Contract
Explanation: Writing tests that assert on thrown exceptions instead of outcome properties creates fragile test suites that break when error modeling changes.
Fix: Assert on outcome.IsFailure, outcome.Error.Code, and outcome.Error.Message. Use parameterized tests to verify multiple failure paths without relying on exception throwing.
7. Validator-Handler Coupling
Explanation: Running validation inside the handler duplicates logic and forces the handler to manage both validation and execution concerns.
Fix: Use a MediatR pipeline behavior to intercept commands, run FluentValidation, and convert failures to OperationResult.Failure before the handler executes. This keeps handlers focused on business logic and ensures consistent validation-to-outcome conversion.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, rapid prototyping | Exception-driven with global middleware | Faster initial development, lower boilerplate | Low upfront, high maintenance as scale increases |
| Enterprise, AI-assisted workflow | Explicit OperationResult<T> with scoped rules | Predictable AI generation, consistent testing, clear retry boundaries | Medium upfront, significantly lower long-term drift |
| Legacy migration | Gradual adoption via pipeline behavior | Allows incremental refactoring without breaking existing endpoints | Low risk, phased investment |
| High-traffic API | Explicit results + centralized mapping | Deterministic HTTP semantics, prevents retry storms, improves observability | High upfront, optimal operational cost |
Configuration Template
# .cursorrules (Place in Application/ directory)
## Error Modeling Contract
- Return OperationResult<T> for all command/query handlers.
- Business failures: OperationResult.Failure(new ErrorDetail("CODE", "Message"))
- Infrastructure failures: Catch exceptions, return OperationResult.Failure(new ErrorDetail("INFRA_ERROR", ex.Message))
- Never throw for domain violations, missing records, or validation failures.
- API endpoints map outcomes to HTTP status codes. No try/catch in controllers.
- Validators execute before handlers. Validation failures become OperationResult.Failure automatically.
- Exceptions are reserved for programmer errors, cancellation, and unrecoverable system faults.
// ADR Template: Application Error Handling
# ADR-009: Explicit Outcome Modeling for Application Layer
## Status: Accepted
## Context
AI assistants default to exception throwing and null returns. This creates inconsistent HTTP semantics, breaks testability, and mixes transient/business failures.
## Decision
All application handlers return OperationResult<T>. Business violations use explicit failure codes. Infrastructure exceptions are caught and converted. API boundaries map outcomes to HTTP responses.
## Consequences
- Predictable AI generation
- Centralized error mapping
- Clear retry policy boundaries
- Increased boilerplate for outcome construction (mitigated by factory methods)
Quick Start Guide
- Create the outcome type: Add
OperationResult<T> and ErrorDetail to your shared domain layer. Keep it flat with Success/Failure factories.
- Update one handler: Refactor a single command handler to return
OperationResult<T>. Replace throw statements with OperationResult.Failure().
- Add scoped rules: Place the
.cursorrules template in your Application/ directory. Commit it to version control.
- Map at the boundary: Update one minimal API endpoint to translate the outcome to HTTP status codes using a switch expression.
- Verify with tests: Write a unit test asserting
outcome.IsFailure and outcome.Error.Code. Run the test to confirm the contract holds.
This pattern scales predictably. Once the contract is established and scoped rules are in place, AI assistants generate compliant code consistently, eliminating architectural drift and ensuring that error handling remains a deliberate, observable system property rather than an accidental implementation detail.