Response envelope & versioning
Going forward, every new endpoint returns a uniform envelope: { success: true, data } on success and
{ success: false, traceId, errors } on failure (the error contract).
Pre-existing endpoints keep their original wire shape unchanged — they are grandfathered, not migrated.
When an existing endpoint needs the envelope, it gets a parallel v2 action on the same controller and
route (selected by the X-Api-Version header) rather than a change to the v1 one. This note is the rationale; the code and backend/CLAUDE.md are the
rule.
Principle: one shape forward, nothing changes underneath
The envelope is the default so that a new endpoint gets it for free — no per-author opt-in to remember, no silent drift. But a shipped endpoint's bytes are a contract with its consumers, so we never change them in place. The two goals meet by making the framing a property of the endpoint, chosen by the action, with the envelope as the default and a frozen list of exceptions:
New endpoints are enveloped by default. Existing endpoints keep their exact wire shape. A consumer that wants the envelope moves to a new version of the endpoint — the old one never changes.
The construct
The responder frames the body; the DTO doesn't know it exists. A response type is a plain record
describing a payload — no marker interface, no render methods, no base class. Which envelope (if any) that
payload is wrapped in is decided by the action, because it is a fact about the endpoint's contract rather
than about the data. The ErrorTypes→HTTP-status map is universal and independent of the body shape:
// Ok/Created envelope both branches — what every new endpoint uses.
protected ActionResult<T> Ok<T>(Result<T> result) where T : notnull => Respond(result, 200, legacy: null);
// The frozen pre-envelope set, and only it.
protected ActionResult<T> OkLegacy<T>(Result<T> result, LegacyResponseShape shape) where T : notnull => Respond(result, 200, shape);
private ActionResult<T> Respond<T>(Result<T> result, int successStatus, LegacyResponseShape? legacy) where T : notnull
{
if (result.IsSuccess)
{
var value = result.GetValue()!;
return StatusCode(successStatus, legacy is null ? new ApiSuccessResponse<T>(true, value) : value);
}
var error = result.GetError()!;
return StatusFor(error, legacy is LegacyResponseShape.RawBodyAndError
? error
: ApiErrorResponse.From(error, TraceId));
}
Ok is an overload alongside ControllerBase.Ok (different signature — a Result<T>), so it doesn't
hide the base. One sharp edge: Result<T> converts implicitly from T, so Ok(someDto) may bind here
and gain an envelope. An action holding a value rather than a Result<T> — because it needed the value
before a side effect, like AuthController.Login signing the cookie — returns StatusCode(200, value).
Body-less paths (204s, custom status) call FromError(DomainError), which renders the raw
DomainError — the shape the pre-existing endpoints that use it keep.
Sibling responders. The same render + status split has two more entry points on ApiController, so
every response kind goes through one place:
Created<T>(Result<T>)— the 201 sibling ofOk<T>(identical render, 201 on success). For a creation endpoint whose response type keeps its contract (e.g.OpenCaseResultrenders a raw 201 body but the envelope error).File(Result<FileDownload>)— a download endpoint whose success payload is bytes, not JSON. It streams theFileDownload(stream + content-type + filename) on success and routes a failure through the error envelope (a file endpoint is go-forward — no grandfathered raw error). The bytes bypass the envelope.
Paginated lists. One carrier per pagination style, shared by every endpoint and every contract
version: PagedData<T>(Items, Page, PageSize, Total) for offset, CursorPagedData<T>(Items, NextCursor)
for keyset. When enveloped, the pagination nests under data:
{ success: true, data: { items, page, pageSize, total } } — consistent with every other enveloped
response, not special-cased as top-level siblings. There were previously two structurally identical pairs
here (PagedResult<T>/CursorPagedData<T> and a bespoke CaseListResult/PagedData<T>) existing only so
one of each could carry a raw render tag; with framing chosen at the call site, one type does both.
Unhandled exceptions. A throw (not a Result failure) is caught by ExceptionMiddleware (first in the
pipeline) and mapped to a 500 in the same error envelope — { success:false, traceId, errors:[{ code: "UnexpectedError", … }] } — so a client parses one error shape everywhere. The exception detail goes to the
log (with the traceId), never the wire; the request body is not logged. Deliberately the envelope, not
RFC7807 ProblemDetails, so unhandled and handled errors don't split into two client-side shapes.
The envelope records (MyLegalTeam.Application; traceId is supplied by the Api responder, handlers
never touch wire concerns):
public sealed record ApiSuccessResponse<T>(bool Success, T Data) where T : notnull; // { success: true, data }
public sealed record ApiErrorResponse(bool Success, string TraceId, IReadOnlyList<ApiError> Errors)
{
public static ApiErrorResponse From(DomainError error, string traceId) => ...; // success is always false
}
Why the success envelope is generic. Data was originally object, which serialized correctly (the
output formatter writes the runtime type) but could not be named — so an enveloped action's
[ProducesResponseType] could only declare the bare payload, and the published 200 schema was T while
the wire carried { success, data: T }. A client generated from that document reads data off the wrong
level. With the payload generic the declaration is the whole body
([ProducesResponseType<ApiSuccessResponse<CaseDetailResult>>(200)], published as
ApiSuccessResponseOfCaseDetailResult), and two tests keep it honest: EnvelopedResponseDeclarationTests
(source scan — every action calling the enveloping responder declares the envelope, and no frozen raw
action declares one) and OpenApiDocumentationTests (the generated document's 200 schema really is
{ success, data } over the payload's $ref). The notnull constraint is what keeps data out of a
oneOf [null, …] union: the responder only builds this on the success branch, where a payload always
exists.
field/meta are carried on DomainNotification as [JsonIgnore] properties, so they populate the
error envelope (From reads them) while staying out of any raw DomainError body.
Grandfathering: the frozen override list
A finite, frozen set of pre-envelope endpoints call OkLegacy/CreatedLegacy with the shape they
shipped with. This is not versioning; it is one shape going forward plus a grandfather list that never
grows. Two groups, by what the endpoint emitted before:
LegacyResponseShape.RawBodyAndError— the consumed v1 endpoints (me, companies list, provisioning, invites, members, role): raw success and rawDomainError.LegacyResponseShape.RawBody— the cases and paged endpoints (open, list, detail, flags, available-transitions, transition, audit-trail paging): already on the enveloped error contract, so only their success is raw.
The list lives in exactly one place — MyLegalTeam.Tests/Controllers/LegacyResponseShapeTests.cs,
which scans the controllers and fails both when an action joins the list and when a listed action's shape
drifts. That is the main practical gain over the previous design, where the same information was twelve
ToSuccess/ToError override pairs spread across twelve feature folders with nothing tying them
together or stopping a thirteenth being added.
Net effect: no current endpoint emits the success envelope — every shipped endpoint keeps its pre-envelope success bytes. The success envelope reaches the wire only through new (and v2) endpoints.
Versioning: additive, by header
When an existing endpoint needs the envelope, add a new version of it rather than changing the old
one. Versioning is by request header (Asp.Versioning, Configurations/ApiVersioningExtensions.cs):
DefaultApiVersion = 1.0 + AssumeDefaultVersionWhenUnspecified + HeaderApiVersionReader, so a caller
sending no X-Api-Version gets v1 with its shape untouched. Both versions live on one themed
controller, at one route; each action declares which version it answers with [MapToApiVersion]. Since
framing is the responder's job, the two actions share the response DTO and the service method — they
differ by one word:
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("me")] // one route; the version is transport, not path
public class MeController(IAuthService authService) : ApiController
{
[HttpGet]
[MapToApiVersion("1.0")]
public async Task<ActionResult<CurrentAccountResult>> Get(CancellationToken ct) =>
OkLegacy(await authService.GetCurrentAccount(CallerAccountId, ct), LegacyResponseShape.RawBodyAndError);
[HttpGet]
[MapToApiVersion("2.0")]
public async Task<ActionResult<CurrentAccountResult>> GetV2(CancellationToken ct) =>
Ok(await authService.GetCurrentAccount(CallerAccountId, ct));
}
// v1 (raw, frozen) -> GET /me : { id, email, isSuperAdmin, memberships }
// v2 (default envelope) -> GET /me X-Api-Version: 2.0 : { success: true, data: { id, email, … } }
Two consequences of putting the version in the header rather than the path, both accepted deliberately:
- Caches must vary on it. Two contracts share a URL, so a cache keyed on the URL alone would hand a
v1 body to a v2 caller.
Program.cssetsVary: X-Api-Versionon every response, andV2EnvelopeEndpointsHttpTestsasserts it. With path versioning this was free. - A forgotten header is silent.
AssumeDefaultVersionWhenUnspecifiedmeans a client that omits it gets v1 rather than an error — a wrong path 404s loudly, a missing header does not. The one place this bites is an endpoint with no v1 sibling — the case-scoped audit read, and the guard override (POST …/transitions/override) and deadlines read (GET …/deadlines) that came with #43: there the header-less request resolves to v1, which the route does not support, andAsp.Versioningrefuses it with a 400UnsupportedApiVersionproblem body before any action runs — a versioning error rather than the app's own error envelope. (Measured, inOverrideTransitionEndpointHttpTests.)
No v2 response type. There were ten of them (CurrentAccountResultV2, CompanyListV2, …), each
structurally identical to its v1 and existing solely to not override the render methods — a type whose
entire semantic content was the absence of behaviour. They are deleted. Should a v2 contract genuinely need
to diverge in its fields, that is when it earns a distinct DTO; until then the versions differ only in
framing, and framing is the responder's job.
Rules
- New endpoints are enveloped — return
Ok/Created. A response DTO is a plain record and says nothing about its own framing. - Shipped bytes are frozen — a pre-envelope endpoint calls
OkLegacy/CreatedLegacywith its shape, and appears inLegacyResponseShapeTests. That list is closed; it does not grow. - Change by version, not in place — to give an existing endpoint the envelope, add a second action on
the same themed controller at the same route, tagged
[MapToApiVersion("2.0")], calling the same service method and returning the same DTO, withOkinstead ofOkLegacy. Never edit the v1 action, never clone the controller, and don't mint a…V2DTO. - Responses only — request DTOs tolerate additive change; not versioned.
- Version by header —
X-Api-Version, read from the header only (no path segment, no query negotiation), so one endpoint has one URL and the version is transport. - Every action on a versioned controller declares its version — an action without
[MapToApiVersion]answers for both, which makes a v1/v2 pair on one route+verb an ambiguous match at runtime.VersionedControllerRoutesTestsenforces this, plus the absence of anyapiVersionroute segment and of any re-cloned…V2Controller.
Considered and rejected
- Per-version marker interfaces (
IApiV1/IApiV2) — the earlier design used one interface per era so the responder dispatched on the type. Replaced by a single tag with overridable static renders: the same compile-time safety without a marker taxonomy, and it composes with real URL versioning for the cases where two shapes must coexist. - A response tag carrying its own render (
IApiResponsewithstatic virtual ToSuccess/ToError) — tried and reversed. It bought a real compile-time guarantee (an unmarked response type would not build) and let each grandfathered DTO freeze its own shape with no shared "legacy" type. What it cost was worse: it conflated the payload with its framing, so expressing "same data, different envelope" required a whole duplicate type. That produced ten…V2records identical to their v1s, forcedCompanyListto subclassList<CompanyResult>purely to have something nominal to hang the tag on (a publicly mutable response thatSystem.Text.Jsonrenders as an array, so it could never gain a field), and scattered the grandfather list across twelve files. Moving the decision to the responder deletes all of that; the compile-time guarantee is replaced byLegacyResponseShapeTests, which is arguably stronger because the frozen list is now enumerated in one place instead of being implicit in twelve overrides. - Interface and an abstract static base class — moot once the render moved to the responder.
- Global cutover to the envelope — would break existing consumers; keeping them unchanged (and versioning when they must move) is the whole point.
- Transport versioning by URL segment — originally chosen (a
v{version:apiVersion}route segment, so/v2/me) on the grounds that a path names its contract more discoverably than a header. Reversed: in practice it forced a cloned controller per version —CasesV2Controllerwas 92 lines re-declaring seven routes and every parameter binding to change only the response shape, and the duplication grew with each versioned route. Header versioning puts both versions on one themed controller, so versioning one route no longer means cloning its six neighbours. The discoverability argument was real but bought less than the duplication cost;Varyand the silent-default trade above are the price.