0. Purpose & layering
A new framework-agnostic project, FirstClassErrors.RequestBinder, that binds a request DTO (body + route) into a typed command/query of value objects at the primary-adapter boundary. Binding failures become a tree of coded errors under a PrimaryPortError envelope (Incoming direction). Same mechanics as fluent-request-binder, but exception → Outcome at every level (zero throws on the common path). It composes with the domain via .Then.
Layering (each layer references only the one before it):
FirstClassErrors (Core, agnostic) ← FirstClassErrors.RequestBinder (agnostic, references Core) ← FirstClassErrors.RequestBinder.AspNetCore (later; references ASP.NET Core + RequestBinder)
Target framework: .NET Standard 2.0, like Core. The binder returns an Outcome<TCommand> and knows nothing about HTTP — it works the same for a message consumer, a CLI or a gRPC handler.
1. Full surface
// ── Entry ────────────────────────────────────────────────────────────────────────────
Bind.PropertiesOf(request)
.FailWith(envelopeFactory) // envelopeFactory: family inferred (PrimaryPortError at the top)
.WithOptions(options) // optional, PER-binder (see §7) -> RequestBinder<TReq>
// ── Scalar: SimpleProperty(sel) ────────────────────────────────────────────────────────
// convert: Func<TIn, Outcome<T>> (NO-THROW by contract)
.AsRequired(convert) -> RequiredProperty<T>
.AsRequired() -> RequiredProperty<TIn> // no conversion, required only
.AsOptional(convert, rawFallback) -> RequiredProperty<T> // absent -> fallback (re-converted): always a value
.AsOptionalReference(convert) -> OptionalReferenceProperty<T> // where T : class -> .Value : T? (null if absent)
.AsOptionalValue(convert) -> OptionalValueProperty<T> // where T : struct -> .Value : T? (null if absent)
// ── Complex (nested binder): ComplexProperty(sel).FailWith(nestedEnvelope) ─────────────
// bindNested: Func<RequestBinder<TIn>, Outcome<T>> (typically a method group BindXxx)
.AsRequired(bindNested) -> RequiredProperty<T>
.AsOptional(bindNested) -> OptionalReferenceProperty<T> // null if the object is absent
// ── List of simple: ListOfSimpleProperties(sel) ────────────────────────────────────────
.AsRequired(elementConvert) -> RequiredProperty<IReadOnlyList<T>>
.AsOptional(elementConvert) -> RequiredProperty<IReadOnlyList<T>> // [] if absent
// ── List of complex: ListOfComplexProperties(sel).FailWith(nestedEnvelope) ─────────────
.AsRequired(bindElement) -> RequiredProperty<IReadOnlyList<T>>
.AsOptional(bindElement) -> RequiredProperty<IReadOnlyList<T>> // [] if absent
// ── Terminal ───────────────────────────────────────────────────────────────────────────
.Build(() => new TCommand(...)) -> Outcome<TCommand> // constructs ONLY if zero errors; else Outcome.Failure(envelope)
2. Result handles
public sealed class RequiredProperty<T> {
private readonly Outcome<T> _outcome; // encapsulated (future inspection / chaining)
private readonly T _value; // extracted ONCE by the binder — never inside .Value
internal RequiredProperty(Outcome<T> outcome, T value) { _outcome = outcome; _value = value; }
public T Value => _value; // PURE property: returns the field, ZERO GetResultOrThrow, ZERO throw
}
public sealed class OptionalReferenceProperty<T> where T : class { public T? Value => _value; /* pure, null if absent */ }
public sealed class OptionalValueProperty<T> where T : struct { public T? Value => _value; /* pure, null if absent */ }
Rule: .Value is never a disguised method. Extraction (outcome.IsSuccess ? outcome.GetResultOrThrow() : default!, guarded) happens in the binder at the As... call, which also records the error on failure. Inside Build (which only runs when there are zero errors), every .Value is valid.
3. Converter — NO-THROW by contract
convert: Func<TIn, Outcome<T>>. A failure is an Outcome.Failure(codedError), never a throw. Value objects expose smart constructors:
public static Outcome<EmailAddress> Parse(string raw) =>
raw.Contains('@') ? Outcome<EmailAddress>.Success(new EmailAddress(raw))
: Outcome<EmailAddress>.Failure(EmailError.Invalid(raw));
4. Error model
- Each field failure is a coded error (code + path in context + public/diagnostic messages).
- Collect-all into the envelope declared by
FailWith (PrimaryPortError).
- Nesting: a
bindNested returns an Outcome<T> whose failure is a child envelope → merged into the parent (PrimaryPortInnerErrors.Add(DomainError | PrimaryPortError), transience derived). FailWith marks every level.
- Paths: prefixed per level,
[index] for lists. Richer tree than fluent-request-binder's flat ValidationError { path, message }.
5. NO-THROW contract
- Common path (even all-invalid) = zero exceptions.
- Nesting = returned
Outcome merged (no throw/catch — vs fluent-request-binder's BadRequestException). Build is non-throwing.
- A genuine bug in a converter → propagates to the host's exception boundary (e.g. ASP.NET filter → 500). The binder catches nothing (mirrors fluent-request-binder's rethrow of non-handled exceptions).
Outcome = expected (400); exception = bug (500).
6. fluent-request-binder → this design
| fluent-request-binder (exception-based) |
This design (first-class error) |
convert throws (configured type), caught |
convert: Func<TIn, Outcome<T>> returns Outcome.Failure |
ComplexProperty: try nested; catch BadRequestException; RecordErrors |
bindNested returns Outcome; parent merges |
List: per index try; catch under name[i] |
per index returns Outcome; merged under [i] |
AssertHasNoError() → throws |
Build() → returns Outcome<TCommand> |
RequiredProperty.Value / OptionalProperty.Value throw |
.Value pure (guarded extraction upstream) |
ValidationError { path, message } (flat) |
coded error + context + tree |
HandledExceptionType |
removed (no exception to classify) |
PropertyNameProvider |
kept (see §7) |
7. Configuration (per-binder)
PropertyNameProvider (pluggable, PropertyInfo → name): decides the field name used in error paths. Default = C# name; plug a JSON/serializer-aware provider so the path matches the client-facing key. Passed via .WithOptions(...), per-binder (not fluent-request-binder's global mutable static singleton).
HandledExceptionType: removed.
8. Composition with the domain
Bind (structural: parse/convert fields) ──► Outcome<Command>
└─► .Then(cmd => domain rules on the value objects) // DomainError, fail-fast
└─► .Finally(onSuccess, onFailure) // e.g. -> HTTP response
Collect-all lives in the binder; the domain stays factories + .Then + modeled composite errors.
9. Reference example
public static class PlaceBookingCommandValidator {
public static Outcome<PlaceBookingCommand> Validate(PlaceBookingRequest req) {
var bind = Bind.PropertiesOf(req).FailWith(InvalidBookingCommandError.Invalid);
var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
var reference = bind.SimpleProperty(r => r.Reference ).AsRequired();
var currency = bind.SimpleProperty(r => r.Currency ).AsOptional(Currency.Parse, "EUR");
var promo = bind.SimpleProperty(r => r.PromoCode ).AsOptionalReference(PromoCode.Parse);
var maxNights = bind.SimpleProperty(r => r.MaxNights ).AsOptionalValue(PositiveInt.Parse);
var stay = bind.ComplexProperty(r => r.Stay ).FailWith(InvalidStayError.Invalid ).AsRequired(BindStay);
var billing = bind.ComplexProperty(r => r.BillingAddress).FailWith(InvalidAddressError.Invalid).AsOptional(BindAddress);
var roomTypes = bind.ListOfSimpleProperties(r => r.RoomTypes).AsRequired(RoomType.Parse);
var tags = bind.ListOfSimpleProperties(r => r.Tags ).AsOptional(Tag.Parse);
var guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(InvalidGuestError.Invalid).AsRequired(BindGuest);
return bind.Build(() => new PlaceBookingCommand(
email.Value, reference.Value, currency.Value, promo.Value, maxNights.Value,
stay.Value, billing.Value, roomTypes.Value, tags.Value, guests.Value));
}
// A complex binder = a dedicated method (envelope-agnostic, injected at the call site via FailWith)
private static Outcome<Stay> BindStay(RequestBinder<StayDto> s) {
var checkIn = s.SimpleProperty(x => x.CheckIn ).AsRequired(BookingDate.Parse);
var checkOut = s.SimpleProperty(x => x.CheckOut).AsRequired(BookingDate.Parse);
return s.Build(() => new Stay(checkIn.Value, checkOut.Value));
}
private static Outcome<Address> BindAddress(RequestBinder<AddressDto> a) {
var street = a.SimpleProperty(x => x.Street ).AsRequired(Street.Parse);
var zip = a.SimpleProperty(x => x.ZipCode).AsRequired(ZipCode.Parse);
var city = a.SimpleProperty(x => x.City ).AsRequired(City.Parse);
return a.Build(() => new Address(street.Value, zip.Value, city.Value));
}
private static Outcome<Guest> BindGuest(RequestBinder<GuestDto> g) {
var first = g.SimpleProperty(x => x.FirstName).AsRequired(Name.Parse);
var last = g.SimpleProperty(x => x.LastName ).AsRequired(Name.Parse);
var mail = g.SimpleProperty(x => x.Email ).AsOptionalReference(EmailAddress.Parse);
return g.Build(() => new Guest(first.Value, last.Value, mail.Value));
}
}
Resulting error tree (e.g. invalid email + inconsistent Stay + Guests[1] missing first name):
BOOKING_COMMAND_INVALID (PrimaryPortError)
├─ GUEST_EMAIL_INVALID { path: GuestEmail }
├─ STAY_INVALID (envelope) { path: Stay }
│ └─ CHECK_IN_NOT_BEFORE_CHECK_OUT { path: Stay.CheckIn }
└─ GUEST_INFO_INVALID (index 1) { path: Guests[1] }
└─ GUEST_FIRST_NAME_REQUIRED { path: Guests[1].FirstName }
10. Out of scope for this project (next steps)
error.ToProblemDetails() (RFC 9457) and DI/ASP.NET wiring → the separate FirstClassErrors.RequestBinder.AspNetCore project.
- An optional per-field chaining hook on
RequiredProperty (e.g. .Must(...)).
- Reusable value-object binders (
StayBinder, …) are just Bind.PropertiesOf(...).Build(...).
Suggested implementation order
- Internal
Outcome extraction + RequiredProperty<T> / OptionalReferenceProperty<T> / OptionalValueProperty<T>.
Bind.PropertiesOf / RequestBinder<TReq> + FailWith + Build.
SimpleProperty (all As... variants).
ComplexProperty (nested binder + FailWith).
ListOfSimpleProperties / ListOfComplexProperties.
- Unit tests throughout.
This spec is the frozen design agreed for v1; implementation commits/PRs will reference this issue via Refs: #NN.
0. Purpose & layering
A new framework-agnostic project,
FirstClassErrors.RequestBinder, that binds a request DTO (body + route) into a typed command/query of value objects at the primary-adapter boundary. Binding failures become a tree of coded errors under aPrimaryPortErrorenvelope (Incoming direction). Same mechanics as fluent-request-binder, butexception → Outcomeat every level (zero throws on the common path). It composes with the domain via.Then.Layering (each layer references only the one before it):
FirstClassErrors(Core, agnostic) ←FirstClassErrors.RequestBinder(agnostic, references Core) ←FirstClassErrors.RequestBinder.AspNetCore(later; references ASP.NET Core + RequestBinder)Target framework: .NET Standard 2.0, like Core. The binder returns an
Outcome<TCommand>and knows nothing about HTTP — it works the same for a message consumer, a CLI or a gRPC handler.1. Full surface
2. Result handles
3. Converter — NO-THROW by contract
convert: Func<TIn, Outcome<T>>. A failure is anOutcome.Failure(codedError), never a throw. Value objects expose smart constructors:4. Error model
FailWith(PrimaryPortError).bindNestedreturns anOutcome<T>whose failure is a child envelope → merged into the parent (PrimaryPortInnerErrors.Add(DomainError | PrimaryPortError), transience derived).FailWithmarks every level.[index]for lists. Richer tree than fluent-request-binder's flatValidationError { path, message }.5. NO-THROW contract
Outcomemerged (no throw/catch — vs fluent-request-binder'sBadRequestException).Buildis non-throwing.Outcome= expected (400); exception = bug (500).6. fluent-request-binder → this design
convertthrows (configured type), caughtconvert: Func<TIn, Outcome<T>>returnsOutcome.FailureComplexProperty:try nested; catch BadRequestException; RecordErrorsbindNestedreturnsOutcome; parent mergestry; catchundername[i]Outcome; merged under[i]AssertHasNoError()→ throwsBuild()→ returnsOutcome<TCommand>RequiredProperty.Value/OptionalProperty.Valuethrow.Valuepure (guarded extraction upstream)ValidationError { path, message }(flat)HandledExceptionTypePropertyNameProvider7. Configuration (per-binder)
PropertyNameProvider(pluggable,PropertyInfo → name): decides the field name used in error paths. Default = C# name; plug a JSON/serializer-aware provider so the path matches the client-facing key. Passed via.WithOptions(...), per-binder (not fluent-request-binder's global mutable static singleton).HandledExceptionType: removed.8. Composition with the domain
Collect-all lives in the binder; the domain stays factories +
.Then+ modeled composite errors.9. Reference example
Resulting error tree (e.g. invalid email + inconsistent Stay + Guests[1] missing first name):
10. Out of scope for this project (next steps)
error.ToProblemDetails()(RFC 9457) and DI/ASP.NET wiring → the separateFirstClassErrors.RequestBinder.AspNetCoreproject.RequiredProperty(e.g..Must(...)).StayBinder, …) are justBind.PropertiesOf(...).Build(...).Suggested implementation order
Outcomeextraction +RequiredProperty<T>/OptionalReferenceProperty<T>/OptionalValueProperty<T>.Bind.PropertiesOf/RequestBinder<TReq>+FailWith+Build.SimpleProperty(allAs...variants).ComplexProperty(nested binder +FailWith).ListOfSimpleProperties/ListOfComplexProperties.This spec is the frozen design agreed for v1; implementation commits/PRs will reference this issue via
Refs: #NN.