Skip to content

Design spec: FirstClassErrors.RequestBinder (framework-agnostic request/command binder) #126

Description

@Reefact

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

  1. Internal Outcome extraction + RequiredProperty<T> / OptionalReferenceProperty<T> / OptionalValueProperty<T>.
  2. Bind.PropertiesOf / RequestBinder<TReq> + FailWith + Build.
  3. SimpleProperty (all As... variants).
  4. ComplexProperty (nested binder + FailWith).
  5. ListOfSimpleProperties / ListOfComplexProperties.
  6. Unit tests throughout.

This spec is the frozen design agreed for v1; implementation commits/PRs will reference this issue via Refs: #NN.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions