Result and Railway-Oriented Programming: Errors as Values and Accumulating Validation
Railway-oriented programming connects steps that can fail into a two-track flow with Bind: if a step fails, later steps don't run and the error is carried to the end as a value, while errors from independent form fields are accumulated. We build Bookstore's order flow with Map, Bind, Ensure, Tap and Match, and show Task<Result<T>> chains, mapping to TypedResults and ValidationProblem, the Angular UI state and the line between exceptions and Result, all with real output.
Version note: The code in this post was built and run with C# 14, .NET 10 (SDK 10.0.402) and ASP.NET Core 10; the TypeScript examples with TypeScript 7.0.2, Node.js 26 and Angular 22.2 (@angular/core 22.2.6).
Guess First: Three Invalid Fields, How Many Error Messages?
The order form of Bookstore, the online bookstore used as the shared example across this site's series, has three fields: email, the book's ISBN and quantity. Each field has its own validation function returning a Result, and the three are chained with Bind, as we saw in the Design Patterns series:
// Short-circuit: stops at the first error
public static Result<ValidOrderRequest> ValidateFirstError(PlaceOrderRequest request) =>
ValidateEmail(request.Email)
.Bind(email => ValidateIsbn(request.Isbn)
.Bind(isbn => ValidateQuantity(request.Quantity)
.Map(quantity => new ValidOrderRequest(email, isbn, quantity))));
Ada fills in the form in a hurry: the email has a dot instead of @, the last digit of the ISBN is wrong and the quantity is zero. All three fields are invalid. How many error messages does the customer see when the form is submitted?
PlaceOrderRequest broken = new("ada.example.com", "9780441013599", 0, null);
Result<ValidOrderRequest> result = OrderValidation.ValidateFirstError(broken);
Console.WriteLine(result);
Console.WriteLine($"errors shown to the customer: {result.Errors.Count}");
The Answer: Bind Leaves the Track at the First Error
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-guess
Failure(email.invalid)
errors shown to the customer: 1
One. Bind never calls the next function when the previous step failed; the ISBN and the quantity weren't even checked. Ada fixes the email and submits again, and this time gets the ISBN error; she fixes that, and now it's the quantity. Three attempts for three errors. In the order flow this behaviour is exactly what we want (no payment if there's no stock), but when validating a form it wears the user out.
This post is about exactly these two behaviours, composition that short-circuits and composition that accumulates errors, and when to use which. What Result is and how it compares to exceptions and the Try pattern is covered in the Clean Code series post "Error Handling in Clean Code: Exceptions, the Try Pattern and the Result Pattern"; a small Result that chains steps with Then appears in the Design Patterns series post "Code-Level Patterns Beyond GoF: Null Object, Specification, Result and Pipeline". We don't repeat those definitions here; on top of them we add the methods composition needs, accumulating validation, async chains, and the HTTP and UI boundaries.
Completing Result for Composition
The Option of the previous post said "there is no value"; Result adds "why not". The failure side carries a list of errors, because we're about to accumulate them:
public sealed record Error(string Code, string Message);
// A class, not a struct: default(Result<T>) would otherwise be a "success" with no value
public sealed class Result<T>
{
private readonly T? _value;
private Result(T value) => (_value, Errors) = (value, []);
private Result(IReadOnlyList<Error> errors) => Errors = errors;
public IReadOnlyList<Error> Errors { get; }
public bool IsSuccess => Errors.Count == 0;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new([error]);
public static Result<T> Failure(IReadOnlyList<Error> errors) =>
errors.Count > 0 ? new(errors) : throw new ArgumentException("A failure needs at least one error.", nameof(errors));
public static implicit operator Result<T>(T value) => Success(value);
public static implicit operator Result<T>(Error error) => Failure(error);
public Result<TOut> Map<TOut>(Func<T, TOut> map) =>
IsSuccess ? Result<TOut>.Success(map(_value!)) : Result<TOut>.Failure(Errors);
public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> bind) =>
IsSuccess ? bind(_value!) : Result<TOut>.Failure(Errors);
public Result<T> Ensure(Func<T, bool> predicate, Func<T, Error> error) =>
IsSuccess && !predicate(_value!) ? Failure(error(_value!)) : this;
public Result<T> Tap(Action<T> action)
{
if (IsSuccess)
action(_value!);
return this;
}
public TOut Match<TOut>(Func<T, TOut> success, Func<IReadOnlyList<Error>, TOut> failure) =>
IsSuccess ? success(_value!) : failure(Errors);
public override string ToString() =>
IsSuccess ? $"Success({_value})" : $"Failure({string.Join(", ", Errors.Select(error => error.Code))})";
}
Each of the five methods has a clear role. Map transforms the successful value and is for steps that can't fail. Bind connects a step that can fail itself. Ensure checks a condition on the successful value and turns it into an error if the condition doesn't hold; it lets you add rules such as "the quantity can't exceed the stock" to the chain without writing a separate validation function. Tap runs a side effect without changing the value (writing a log, for instance). Match brings the two tracks down to a single value at the end of the chain.
Two design decisions differ from Option. Result is a class: for Option, default meaning None made sense, but for Result default has no sensible meaning; as a struct, default(Result<T>) would look "successful" because its error list is empty, and a default(T) would come out of it. Second, thanks to the implicit conversions, validation functions can return either the value or an Error directly:
// Pure validation rules: no I/O, no clock
public static class OrderValidation
{
public const int MaxQuantity = 20;
public static Result<string> ValidateEmail(string? email) =>
string.IsNullOrWhiteSpace(email) ? new Error("email.missing", "Email is required.")
: email.Contains('@') && email.IndexOf('.', email.IndexOf('@')) > 0 ? email.Trim()
: new Error("email.invalid", $"'{email}' is not a valid email address.");
public static Result<string> ValidateIsbn(string? isbn)
{
string digits = new((isbn ?? "").Where(char.IsAsciiDigit).ToArray());
if (digits.Length != 13)
return new Error("isbn.length", "ISBN-13 must have 13 digits.");
int sum = digits.Take(12).Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum();
int check = (10 - sum % 10) % 10;
return check == digits[12] - '0' ? digits : new Error("isbn.checksum", $"{digits} has a wrong check digit.");
}
public static Result<int> ValidateQuantity(int quantity) =>
quantity is >= 1 and <= MaxQuantity ? quantity
: new Error("quantity.range", $"Quantity must be between 1 and {MaxQuantity}.");
}
The email check is deliberately crude; real validation (the domain exists, the address belongs to the user) happens with a confirmation email. The point here isn't the rules themselves but the way they're combined.
Railway-Oriented Programming: Two Tracks and Switches
In his 2014 talks (Functional Programming eXchange and NDC), Scott Wlaschin named this style of composition railway-oriented programming: one track along which the successful value travels, a second track for errors, and switches between them. Every step that can fail is a switch; if an error occurs, the flow moves to the second track and never comes back. Bookstore's order flow looks like this in that model:

public Task<Result<PlacedOrder>> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default) =>
OrderValidation.Validate(request)
.Bind(valid => FindBook(valid.Isbn).Map(book => (valid, book)))
.BindAsync(draft => stock.ReserveAsync(draft.book.Isbn, draft.valid.Quantity, ct).MapAsync(_ => draft))
.MapAsync(draft => Price(draft.valid, draft.book, request.CouponCode))
.TapAsync(order => log.WriteLine($"[order] {order.OrderNo} {order.Line.Quantity} x {order.Line.Isbn} total={order.Totals.Total}"));
You can read the flow from top to bottom: validate, find the book, reserve stock, price it, write the log. FindBook uses the previous post's Option and turns "not found" into an error; this is exactly where Option can't say why the value is missing:
public Result<CatalogBook> FindBook(string isbn) =>
catalog.Find(isbn).Match(
book => Result<CatalogBook>.Success(book),
() => new Error("book.not_found", $"No book with ISBN {isbn}."));
Price calls the functional core we've been growing since the third post (CheckoutCalculator.Calculate); it can't fail, so it's connected with Map. Let's try four requests: a valid order, a book that isn't in the catalog, an order the stock can't cover and the broken form from the guess:
foreach (PlaceOrderRequest request in requests)
{
Result<PlacedOrder> result = await placement.PlaceAsync(request);
Console.WriteLine(result.Match(
order => $"placed {order.OrderNo}: {order.Totals.Total} TL",
errors => $"rejected: {string.Join(", ", errors.Select(error => error.Code))}"));
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-place
[order] BS-2026-0042 2 x 9780441013593 total=341.10
placed BS-2026-0042: 341.10 TL
rejected: book.not_found
rejected: stock.insufficient
rejected: email.invalid, isbn.checksum, quantity.range
The log line was written only for the successful order; Tap never ran on the failure track. After the first order reserved two copies of Dune, one was left in stock, so Grace's two-book order moved to the failure track at the stock step and was never priced. The last line has three errors at once: this time validation didn't short-circuit.
Accumulating Validation: All Errors at Once
Instead of a Bind chain, Validate uses a Combine that evaluates the three results side by side:
// Runs every validation and collects all errors instead of stopping at the first one
public static Result<TOut> Combine<T1, T2, T3, TOut>(
Result<T1> first, Result<T2> second, Result<T3> third, Func<T1, T2, T3, TOut> combine) =>
first.IsSuccess && second.IsSuccess && third.IsSuccess
? first.Bind(a => second.Bind(b => third.Map(c => combine(a, b, c))))
: Result<TOut>.Failure([.. first.Errors, .. second.Errors, .. third.Errors]);
// Accumulating: reports every invalid field at once
public static Result<ValidOrderRequest> Validate(PlaceOrderRequest request) =>
Result.Combine(
ValidateEmail(request.Email),
ValidateIsbn(request.Isbn),
ValidateQuantity(request.Quantity),
(email, isbn, quantity) => new ValidOrderRequest(email, isbn, quantity));
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-combine
Failure(email.invalid, isbn.checksum, quantity.range)
email.invalid: 'ada.example.com' is not a valid email address.
isbn.checksum: 9780441013599 has a wrong check digit.
quantity.range: Quantity must be between 1 and 20.
Success(ValidOrderRequest { Email = ada@example.com, Isbn = 9780441013593, Quantity = 2 })
Here's the difference: in a Bind chain the input of the second step is the output of the first. You can't run the ISBN validation before the email is valid, because you're inside the lambda. In Combine the three validations are independent; each runs on its own input and the results are combined at the end. In the functional programming literature this independent combination is called applicative, while the sequential, dependent composition of Bind is the monad. The practical rule: if the steps need each other's results (no stock check before the book is found), use Bind; if they're independent (the fields of a form), accumulate.
Combine taking three parameters is a limitation; a four-field form needs another overload. Libraries solve this either with many overloads or with a builder that collects the errors outside. Note also that writing the ISBN with hyphens (978-0-441-01359-3) is accepted: ValidateIsbn both checks and returns the normalised value. The output of validation isn't just a bool but a value the next step can use safely (ValidOrderRequest), which is the idea popularised by Alexis King's 2019 post as "parse, don't validate".
Async Steps: Task<Result<T>> Chains
In real life stock reservation goes to the database, so it's async: Task<Result<Reservation>>. The lambda passed to Bind now returns a Task, and the chain continues on Task<Result<T>>. A few extensions are enough for that:
public static async Task<Result<TOut>> BindAsync<T, TOut>(this Result<T> result, Func<T, Task<Result<TOut>>> bind) =>
await result.Match(bind, errors => Task.FromResult(Result<TOut>.Failure(errors)));
public static async Task<Result<TOut>> BindAsync<T, TOut>(this Task<Result<T>> task, Func<T, Task<Result<TOut>>> bind) =>
await (await task).BindAsync(bind);
public static async Task<Result<TOut>> MapAsync<T, TOut>(this Task<Result<T>> task, Func<T, TOut> map) =>
(await task).Map(map);
public static async Task<Result<T>> TapAsync<T>(this Task<Result<T>> task, Action<T> action) =>
(await task).Tap(action);
To be honest: async railway chains don't feel as natural in C# as they do in F#. Every method needs separate sync and async overloads, on both Result and Task<Result>; the tuple in PlaceAsync (draft.valid, draft.book) is the price of carrying more than one value between steps; and when an error occurs, the stack trace shows BindAsync, MapAsync and the compiler-generated state machine frames. The same flow written with await and early returns is longer, but any C# developer can read it at first glance:
// The same flow with await and early returns
public async Task<Result<PlacedOrder>> PlaceWithAwaitAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
Result<ValidOrderRequest> valid = OrderValidation.Validate(request);
if (!valid.IsSuccess)
return Result<PlacedOrder>.Failure(valid.Errors);
ValidOrderRequest input = valid.Match(v => v, _ => throw new InvalidOperationException());
Result<CatalogBook> book = FindBook(input.Isbn);
if (!book.IsSuccess)
return Result<PlacedOrder>.Failure(book.Errors);
CatalogBook found = book.Match(b => b, _ => throw new InvalidOperationException());
Result<Reservation> reservation = await stock.ReserveAsync(found.Isbn, input.Quantity, ct);
if (!reservation.IsSuccess)
return Result<PlacedOrder>.Failure(reservation.Errors);
PlacedOrder order = Price(input, found, request.CouponCode);
log.WriteLine($"[order] {order.OrderNo} {order.Line.Quantity} x {order.Line.Isbn} total={order.Totals.Total}");
return order;
}
A [Theory] checks that the two versions give the same result for four different requests (Railway_and_await_versions_agree). Which one to choose is the team's call. My preference: railway for the sync chains in the domain core (validation, pricing), await and early returns in the shell, where the I/O is. How async works internally is covered in the C# Async series post "How C# async/await Works: The State Machine and Continuations".
From Result to an HTTP Response in ASP.NET Core
Turning a Result into HTTP should happen in one place, at the endpoint boundary. The Clean Code post did this with Results.Problem; here we add two things: turning accumulated validation errors into a per-field ValidationProblem, and describing the return type to the compiler with TypedResults:
public sealed record OrderResponse(string OrderNo, string Isbn, int Quantity, decimal Total);
public static class ResultHttp
{
// Business failures that are not about a form field
private static readonly Dictionary<string, int> StatusByCode = new()
{
["book.not_found"] = StatusCodes.Status404NotFound,
["stock.insufficient"] = StatusCodes.Status409Conflict
};
public static Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult> ToHttpResult(this Result<PlacedOrder> result) =>
result.Match<Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult>>(
order => TypedResults.Created(
$"/orders/{order.OrderNo}",
new OrderResponse(order.OrderNo, order.Line.Isbn, order.Line.Quantity, order.Totals.Total)),
errors => StatusByCode.TryGetValue(errors[0].Code, out int status)
? TypedResults.Problem(title: errors[0].Message, statusCode: status, type: $"https://bookstore.example.com/errors/{errors[0].Code}")
: TypedResults.ValidationProblem(errors
.GroupBy(error => error.Code.Split('.')[0])
.ToDictionary(group => group.Key, group => group.Select(error => error.Message).ToArray())));
}
app.MapPost("/orders", async (PlaceOrderRequest request, OrderPlacement placement, CancellationToken ct) =>
(await placement.PlaceAsync(request, ct)).ToHttpResult());
Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult> is a sum type too: the endpoint returns either 201, 400 or a problem response. According to the Minimal API responses page on Microsoft Learn, this type gives a compile error if you try to return a result that isn't in the list, and it adds the OpenAPI metadata to the endpoint by itself. Let's run the API and send three requests:
$ dotnet run --project src/Bookstore.Fp.Api --urls http://127.0.0.1:52107
$ curl ... -d '{"email":"ada@example.com","isbn":"9780441013593","quantity":2,"couponCode":"autumn10"}'
{"orderNo":"BS-2026-0042","isbn":"9780441013593","quantity":2,"total":341.10}
HTTP 201 application/json; charset=utf-8
$ curl ... -d '{"email":"ada.example.com","isbn":"9780441013599","quantity":0}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"email":["'ada.example.com' is not a valid email address."],"isbn":["9780441013599 has a wrong check digit."],"quantity":["Quantity must be between 1 and 20."]},"traceId":"00-5b92b8bedb4be25acac808ec628c2535-36b7a687632caab1-00"}
HTTP 400 application/problem+json
$ curl ... -d '{"email":"grace@example.com","isbn":"9780441013593","quantity":2}'
{"type":"https://bookstore.example.com/errors/stock.insufficient","title":"Only 1 left for 9780441013593.","status":409,"traceId":"00-4dbabf206a63d49441791978773c8a4e-27ea1b618a1832a8-00"}
HTTP 409 application/problem+json
curl ... is short for curl -s -X POST http://127.0.0.1:52107/orders -H "Content-Type: application/json"; the same three requests are also in the API project's Bookstore.Fp.Api.http file. The three accumulated errors sit in the errors object under their field names, type, title and status are there as the RFC 9457 standard for ProblemDetails requires, and AddProblemDetails() adds a traceId to every response. Because the API uses the real clock (TimeProvider.System), the first order's total comes out without the discount, 379.00, if you run it outside the campaign window.
The mapping between error code and HTTP status lives in the endpoint layer on purpose: the domain says "not enough stock", not "409". When you use the same Result in a background job or a message handler, HTTP concepts haven't leaked into the domain.
The Angular Side: Turning the Error Union into UI State
On the client the same idea continues as a discriminated union. The template shouldn't know HTTP status codes; it's enough to turn the response into a UI state once, in one place, and draw the screen from that state:
export type CheckoutState =
| { readonly kind: "idle" }
| { readonly kind: "invalid"; readonly fieldErrors: Readonly<Record<string, readonly string[]>> }
| { readonly kind: "rejected"; readonly message: string }
| { readonly kind: "placed"; readonly orderNo: string; readonly total: number };
type ProblemDetails = { readonly title?: string; readonly errors?: Record<string, string[]> };
type OrderResponse = { readonly orderNo: string; readonly total: number };
// Turns an HTTP response into one UI state; the template never sees raw status codes
export function toCheckoutState(status: number, body: unknown): CheckoutState {
if (status === 201) {
const order = body as OrderResponse;
return { kind: "placed", orderNo: order.orderNo, total: order.total };
}
const problem = body as ProblemDetails;
if (status === 400 && problem.errors) {
return { kind: "invalid", fieldErrors: problem.errors };
}
return { kind: "rejected", message: problem.title ?? `Unexpected status ${status}` };
}
export function banner(state: CheckoutState): string {
switch (state.kind) {
case "idle":
return "";
case "invalid":
return `Please fix ${Object.keys(state.fieldErrors).length} field(s): ${Object.keys(state.fieldErrors).join(", ")}`;
case "rejected":
return `Order not placed: ${state.message}`;
case "placed":
return `Thank you! Order ${state.orderNo}, total ${state.total.toFixed(2)} TL`;
default:
return assertNever(state);
}
}
In the component the state is a signal and the message on screen is a computed. Without the Angular compiler, a small script that runs in Node with the same signal API sends three requests to the running API:
const state = signal<CheckoutState>({ kind: "idle" });
const message = computed(() => banner(state()));
for (const request of requests) {
const response = await fetch(`${apiUrl}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
state.set(toCheckoutState(response.status, await response.json()));
console.log(`${response.status} ${state().kind.padEnd(8)} ${message()}`);
}
$ node src/07-checkout-state.ts
201 placed Thank you! Order BS-2026-0043, total 59.90 TL
400 invalid Please fix 2 field(s): email, isbn
404 rejected Order not placed: No book with ISBN 9780261103344.
Because the invalid state carries errors per field, the form can put each message under its own field; this is the only place the benefit of accumulated validation actually reaches the user. The assertNever in the switch gives a compile error when a fifth state (say, submitting) is added. The Result type itself in TypeScript is covered in the TypeScript Intermediate series post "Error Handling in TypeScript: unknown in catch, Narrowing Errors and the Result Pattern".
Exception or Result?
Result doesn't replace every error. The distinction is whether the error is an expected business outcome or a failure of the program or the infrastructure. Trying to cancel an order that has already shipped is an expected business outcome: it happens every day on the customer support screen. In the fifth post we blocked this at compile time with types (two overloads that only take Pending and Paid), but when the status comes from the database as OrderStatus, which case it is only becomes known at run time. That's where Result is the right tool:
public static class OrderCancellation
{
// The status comes from the database as OrderStatus, so the check happens at run time
public static Result<Cancelled> TryCancel(OrderStatus status, string reason) => status switch
{
Pending or Paid => new Cancelled(reason),
Shipped shipped => new Error("order.shipped", $"Already shipped with {shipped.TrackingNo}; start a return instead."),
Cancelled => new Error("order.cancelled", "The order is already cancelled."),
_ => throw new UnreachableException($"Unknown status {status.GetType().Name}")
};
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-cancel
Pending -> Cancelled { Reason = customer request }
Paid -> Cancelled { Reason = customer request }
Shipped -> Already shipped with TRK-778; start a return instead.
Cancelled -> The order is already cancelled.
The last arm of the same switch, however, is an exception: an unknown case is a programming error and nobody is expected to "handle" it and carry on. A dropped database connection, a timeout or a full disk should stay exceptions too; wrapping them in Result forces every caller to deal with an error it can't recover from. Wlaschin himself later wrote "Against Railway-Oriented Programming", arguing that the approach shouldn't be taken to extremes and that exceptions are still the right tool for unexpected errors.
Using a library instead of writing your own type is another option. In C#, language-ext (Option, Either, Validation and much more), ErrorOr and FluentResults are common; in TypeScript, neverthrow is lighter and Effect is a whole effect system. When choosing, look at how well it supports async chains, whether it supports accumulating errors, how many concepts the team has to learn and whether it's actively maintained. The 72-line Result.cs in this post is enough for most applications; we'll come back to the team cost of moving to a broad library like language-ext in the last post of the series.
Common Pitfalls
Turning an exception into a Result and losing the stack trace. Writing catch (Exception ex) { return new Error("x", ex.Message); } is easy, but the information about where the error happened disappears on that line:
// Anti-pattern: the exception becomes a message and its stack trace is lost
private static Result<decimal> TryParsePrice(string text)
{
try
{
return PriceText.Parse(text);
}
catch (FormatException ex)
{
return new Error("price.format", ex.Message);
}
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-stack
as Result: The input string '12,50 TL' was not in a correct format.
as exception: 4 frames, ours: at Bookstore.Ordering.Pricing.PriceText.Parse(String text)
The message in the Result says what went wrong but not where; the exception has four frames and the place in our code where it happened. If you're parsing user input, use decimal.TryParse and return a Result without creating an exception at all; if you catch an unexpected exception, either keep throwing it or at least log the exception itself.
Returning a Result and not checking it. The line OrderCancellation.TryCancel(status, reason); compiles and C# doesn't tell you the return value is unused; the code carries on even when the cancellation failed. Make the method names (Try...) and the review question "where is this result used?" a habit. Ending the chain with Match solves this naturally: you can't reach the value without writing both branches.
Making every method return Result. When every layer from the repository to the controller returns Result, unrecoverable errors start getting wrapped in Result too and the code fills up with if (!result.IsSuccess) return result.Errors; lines. Use Result where the business decision is made; let infrastructure errors travel to the boundary as exceptions.
Leaving error codes as arbitrary strings. If "stock.insufficient" is written in one place and "stock.insuficient" in another, the HTTP mapping silently falls back to 400. Collect the codes as constants in a static class ErrorCodes, or model the error types as a sum type; with C# 15 the second option is much easier, and it's the subject of the next post.
Using Tap inside the core. Tap exists to run side effects; put in the middle of the core, it brings back the logging, clock and I/O we pushed aside in the third post. In this post Tap only appears in OrderPlacement, in the shell.
A Small Experiment
★☆☆ Add a fourth rule to Validate: if a coupon code is given, it can be at most 12 characters long. You'll need to write a four-parameter overload of Combine; how would you reduce the repeated code?
★☆☆ Add the rule "the quantity of an e-book can't be more than 1" to the PlaceAsync chain with Ensure. On which track, with which code does the error come back, and which HTTP status does the API turn it into?
★★☆ ResultHttp.ToHttpResult looks only at the first business error through errors[0]. Can validation and business errors come back at the same time? Answer by looking at the flow, then confirm it with a test.
★★★ Model the error codes as a sum type instead of strings (EmailInvalid, IsbnChecksum, BookNotFound, StockInsufficient...) and turn the StatusByCode dictionary into a switch expression. When you add a new error case, does the C# 14 compiler tell you about the missing arm in the switch?
The question in the last exercise leads to the next post. C# 15's union types and closed hierarchies let us write the fifth post's OrderStatus and this post's Result as real sum types. We'll look at how the compiler warnings change, how System.Text.Json serializes these types and what turning an existing public API into a union breaks.