Skip to main content

Error Handling

Synapse embraces the principle that errors are values. Handlers return Result / Result<T> from UnambitiousFx.Functional rather than throwing exceptions for domain errors. Exceptions are reserved for genuine programming errors (misconfiguration, missing handlers).

Result at the call site

Every IInvoker.InvokeAsync call returns a Result or Result<T>. Use whichever consumption pattern fits the context:

TryGet — out-variable style

var result = await invoker.InvokeAsync(new CreateTaskCommand("Buy milk"), ct);

if (result.TryGet(out var id, out var error))
return Results.Created($"/tasks/{id}", id);
else
return Results.Problem(error.ToString());

Match — functional style

return result.Match(
success: id => Results.Created($"/tasks/{id}", id),
failure: error => Results.Problem(error.ToString()));

IsSuccess guard

if (!result.IsSuccess)
{
logger.LogError("Create task failed: {Error}", result.Error);
return;
}
Process(result.Value);

Returning failures from handlers

Return a failure directly from a handler — do not throw:

public async ValueTask<Result<Guid>> HandleAsync(CreateTaskCommand cmd, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(cmd.Title))
return Result.Failure<Guid>("Title is required."); // ← return, not throw

var id = Guid.NewGuid();
await _repo.SaveAsync(id, cmd.Title, ct);
return Result.Success(id);
}

Combining multiple results

Combine a collection of Result / Result<T> values into a single result. It succeeds only when all inputs succeed; otherwise all failures are aggregated:

var results = new[]
{
validator1.ValidateAsync(cmd),
validator2.ValidateAsync(cmd),
};

var combined = results.Combine();
if (!combined.IsSuccess)
return combined; // all individual failures merged

Propagation through pipelines

Failures propagate transparently through the pipeline without any special handling:

  • A behavior that calls next() and gets back Result.Failure(...) can inspect it, transform it, or return it unchanged.
  • A behavior can short-circuit by returning Result.Failure(...) without calling next() at all (e.g., a validation behavior).
public async ValueTask<Result<TResponse>> HandleAsync<TRequest, TResponse>(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
// Short-circuit example
if (!IsAllowed(request))
return Result.Failure<TResponse>("Access denied.");

return await next();
}

Exceptions

Exceptions are thrown only for programming errors — situations that indicate misconfiguration, not expected domain failures:

ExceptionWhen thrown
MissingHandlerExceptionNo handler registered for the request type. This is a startup/configuration error.
CqrsBoundaryViolationExceptionA command was dispatched from inside a query (or vice versa) with CQRS enforcement enabled.
MissingContextFeatureExceptionIContext.GetRequiredFeature<T>() called but the feature was never set.

These should never appear in normal application flow. If they do, fix the configuration rather than catching them.

See also