Rationale
The HTTP writing integration currently bypasses the standard System.Text.Json (STJ) pipeline in the ASP.NET Core Minimal API and MVC layers. Both LightResult / LightResult<T> (Minimal APIs) and LightActionResult / LightActionResult<T> (MVC) attempt to downcast the resolved JsonConverter to HttpWriteResultJsonConverter or HttpWriteResultJsonConverter<T> and then call a custom Serialize method that accepts LightResultsHttpWriteOptions as an additional argument. This design has two key problems:
-
Callers cannot replace converters. Because the ASP.NET Core result types check for a specific converter class via downcast, registering a custom JsonConverter<Result> or JsonConverter<Result<T>> will only be used when the downcast fails (the fallback path), and in that case the LightResultsHttpWriteOptions are silently ignored — producing incorrect output.
-
Options are passed out-of-band. HttpWriteResultJsonConverter receives LightResultsHttpWriteOptions both at construction time (for the Write override) and as an extra argument through the Serialize method (for per-request overrides). This dual-channel design is fragile and unlike the CloudEvents writing feature, which solved the same problem cleanly by embedding resolved options into an intermediary struct that flows through the normal STJ pipeline.
This plan adopts the CloudEvents pattern: introduce intermediary wrapper structs (HttpResultForWriting / HttpResultForWriting<T>) that bundle the Result together with resolved serialization options. Stateless JSON converters serialize these wrapper types through the standard STJ JsonSerializer.Serialize call. The ASP.NET Core layers (both Minimal APIs and MVC) construct the wrapper and hand it to STJ — no downcasts, no special Serialize methods.
Acceptance Criteria
Intermediary Types
JSON Converters for Intermediary Types
ASP.NET Core Minimal API Integration
ASP.NET Core MVC Integration
Shared Base Classes
DI / Module Registration
Code Cleanup
Benchmarks
Technical Details
Intermediary Types
Place these in src/Light.Results/Http/Writing/:
ResolvedHttpWriteOptions
public readonly record struct ResolvedHttpWriteOptions(
ValidationProblemSerializationFormat ValidationProblemSerializationFormat,
MetadataSerializationMode MetadataSerializationMode,
Func<Errors, MetadataObject?, ProblemDetailsInfo>? CreateProblemDetailsInfo,
bool FirstErrorCategoryIsLeadingCategory
);
This struct is a frozen snapshot of LightResultsHttpWriteOptions, created once per request. It avoids repeatedly reading from the mutable options class during serialization. A public extension method provides the conversion:
public static ResolvedHttpWriteOptions ToResolvedHttpWriteOptions(
this LightResultsHttpWriteOptions options
) => new (
options.ValidationProblemSerializationFormat,
options.MetadataSerializationMode,
options.CreateProblemDetailsInfo,
options.FirstErrorCategoryIsLeadingCategory
);
This is the single point of truth for converting mutable options to frozen options. It is called once at the top of ExecuteAsync / ExecuteResultAsync (after resolving the final LightResultsHttpWriteOptions from DI or override) and by the ToHttpResultForWriting extension methods.
Wrapper Types: HttpResultForWriting and HttpResultForWriting<T>
public readonly record struct HttpResultForWriting(
Result Data,
ResolvedHttpWriteOptions ResolvedOptions
);
public readonly record struct HttpResultForWriting<T>(
Result<T> Data,
ResolvedHttpWriteOptions ResolvedOptions
);
Extension Methods
An HttpResultForWritingExtensions static class provides:
public static HttpResultForWriting ToHttpResultForWriting(
this Result result,
LightResultsHttpWriteOptions options
);
public static HttpResultForWriting<T> ToHttpResultForWriting<T>(
this Result<T> result,
LightResultsHttpWriteOptions options
);
These methods create ResolvedHttpWriteOptions from the supplied LightResultsHttpWriteOptions and return the wrapper struct.
Converter Architecture
Place converters in src/Light.Results/Http/Writing/Json/:
Http/Writing/
├── HttpResultForWriting.cs
├── HttpResultForWritingExtensions.cs
├── ResolvedHttpWriteOptions.cs
├── Json/
│ ├── HttpResultForWritingJsonConverter.cs (non-generic + generic + factory)
│ ├── HttpWriteMetadataObjectJsonConverter.cs (unchanged)
│ └── HttpWriteMetadataValueJsonConverter.cs (unchanged)
The converters reuse the existing SerializerExtensions.SerializeProblemDetailsAndMetadata and WriteMetadataPropertyAndValue helpers. The key difference is that the converter reads options from the wrapper's ResolvedOptions field instead of from a constructor-injected LightResultsHttpWriteOptions instance.
Because SerializeProblemDetailsAndMetadata currently accepts LightResultsHttpWriteOptions, it needs a new overload (or refactor) to accept ResolvedHttpWriteOptions. Keep the existing overload if backwards compatibility for direct callers is desired, or replace it since the library is not published yet and breaking changes are allowed.
ASP.NET Core Integration Changes
Base Class Changes
BaseLightResult<TResult> and BaseLightActionResult<TResult> keep LightResultsHttpWriteOptions? as the constructor parameter and OverrideOptions property — this is the user-facing type that callers pass to ToMinimalApiResult, ToMvcActionResult, etc.
The change happens inside ExecuteAsync / ExecuteResultAsync:
- Resolve the final
LightResultsHttpWriteOptions via the existing ResolveLightResultOptions(OverrideOptions) path (override ?? DI).
- Freeze to
ResolvedHttpWriteOptions by calling ToResolvedHttpWriteOptions() — once.
- Pass the single
ResolvedHttpWriteOptions to both SetHeaders and WriteBodyAsync.
SetHeaders signature changes to accept ResolvedHttpWriteOptions instead of reading options from the HttpContext internally. It uses the struct's fields directly for status code, content type, and metadata header decisions.
WriteBodyAsync signature changes to also receive ResolvedHttpWriteOptions so subclasses can construct the envelope without any additional resolution.
LightResult.WriteBodyAsync (Minimal APIs)
The method receives the already-resolved ResolvedHttpWriteOptions from the base class and becomes:
- Resolve
JsonSerializerOptions (existing logic via ResolveJsonSerializerOptions).
- Construct
HttpResultForWriting from the enriched result and the received ResolvedHttpWriteOptions.
- Look up
JsonTypeInfo for HttpResultForWriting from the serializer options.
- Serialize with
JsonSerializer.Serialize(writer, wrapper, typeInfo).
The same applies to LightResult<T>.WriteBodyAsync with HttpResultForWriting<T>.
LightActionResult.WriteBodyAsync (MVC)
Identical pattern, using ResolveMvcJsonSerializerOptions instead.
Extension Methods (ToMinimalApiResult, ToMvcActionResult)
These public extension methods remain unchanged — they accept LightResultsHttpWriteOptions? and pass it straight through to the constructor. No conversion happens here; freezing to ResolvedHttpWriteOptions is deferred to ExecuteAsync / ExecuteResultAsync.
Module Registration
AddDefaultLightResultsHttpWriteJsonConverters currently requires LightResultsHttpWriteOptions and creates converters with it:
// Current
serializerOptions.Converters.Add(new HttpWriteResultJsonConverter(options));
serializerOptions.Converters.Add(new HttpWriteResultJsonConverterFactory(options));
After the change:
// New
serializerOptions.Converters.Add(new HttpResultForWritingJsonConverter());
serializerOptions.Converters.Add(new HttpResultForWritingJsonConverterFactory());
The AddDefaultLightResultsHttpWriteJsonConverters method signature changes to no longer require LightResultsHttpWriteOptions. This simplifies the Module.ConfigureMinimalApiJsonOptionsForLightResults and Module.ConfigureMvcJsonOptionsForLightResults methods: they no longer need the Configure<LightResultsHttpWriteOptions> lambda to inject options into the JSON setup.
Non-Generic Result Body Writing Edge Case
When Result is valid and has no metadata to serialize, the current converter writes nothing at all (empty body). The new converter should preserve this behavior: if the result is valid and there is no metadata to write, HttpResultForWritingJsonConverter should write nothing. This requires the ASP.NET Core layer to check before calling JsonSerializer.Serialize — or the converter can write nothing and the caller omits the content type (which is already handled by SetContentTypeFromResult). Note: the current SetContentTypeFromResult already skips setting a content type for this case. The converter should simply not be invoked when there is nothing to write — this check belongs in WriteBodyAsync.
Performance Considerations
- All intermediary types are readonly record structs — no heap allocations for the wrapper itself.
ResolvedHttpWriteOptions is small (fits in a few machine words) — minimal copy overhead.
- Options are resolved and frozen once at the top of
ExecuteAsync / ExecuteResultAsync. The single ResolvedHttpWriteOptions instance is reused for headers and body writing.
- STJ converter resolution adds minor overhead compared to the current direct downcast, but enables full pipeline extensibility — the same trade-off accepted for CloudEvents.
Rationale
The HTTP writing integration currently bypasses the standard System.Text.Json (STJ) pipeline in the ASP.NET Core Minimal API and MVC layers. Both
LightResult/LightResult<T>(Minimal APIs) andLightActionResult/LightActionResult<T>(MVC) attempt to downcast the resolvedJsonConvertertoHttpWriteResultJsonConverterorHttpWriteResultJsonConverter<T>and then call a customSerializemethod that acceptsLightResultsHttpWriteOptionsas an additional argument. This design has two key problems:Callers cannot replace converters. Because the ASP.NET Core result types check for a specific converter class via downcast, registering a custom
JsonConverter<Result>orJsonConverter<Result<T>>will only be used when the downcast fails (the fallback path), and in that case theLightResultsHttpWriteOptionsare silently ignored — producing incorrect output.Options are passed out-of-band.
HttpWriteResultJsonConverterreceivesLightResultsHttpWriteOptionsboth at construction time (for theWriteoverride) and as an extra argument through theSerializemethod (for per-request overrides). This dual-channel design is fragile and unlike the CloudEvents writing feature, which solved the same problem cleanly by embedding resolved options into an intermediary struct that flows through the normal STJ pipeline.This plan adopts the CloudEvents pattern: introduce intermediary wrapper structs (
HttpResultForWriting/HttpResultForWriting<T>) that bundle theResulttogether with resolved serialization options. Stateless JSON converters serialize these wrapper types through the standard STJJsonSerializer.Serializecall. The ASP.NET Core layers (both Minimal APIs and MVC) construct the wrapper and hand it to STJ — no downcasts, no specialSerializemethods.Acceptance Criteria
Intermediary Types
ResolvedHttpWriteOptionsreadonly record struct captures the frozen, per-request serialization options (derived fromLightResultsHttpWriteOptionsand any per-call overrides).ToResolvedHttpWriteOptions()onLightResultsHttpWriteOptionscreatesResolvedHttpWriteOptions. This is the single public conversion point used by both the base classes and the wrapper construction.HttpResultForWriting(non-generic, wrappingResult) andHttpResultForWriting<T>(wrappingResult<T>) readonly record structs carry the result data together withResolvedHttpWriteOptions.ToHttpResultForWritingonResultandResult<T>construct these wrapper structs.JSON Converters for Intermediary Types
HttpResultForWritingJsonConverterserializesHttpResultForWritingby writing either an empty body / metadata-only object (success) or Problem Details (failure) — reusing existing serialization helpers.HttpResultForWritingJsonConverter<T>serializesHttpResultForWriting<T>by writing the value (possibly wrapped with metadata) on success, or Problem Details on failure.HttpResultForWritingJsonConverterFactorycreates generic converters for anyHttpResultForWriting<T>.ReadthrowsNotSupportedException).ASP.NET Core Minimal API Integration
LightResult.WriteBodyAsyncandLightResult<T>.WriteBodyAsyncconstruct theHttpResultForWriting/HttpResultForWriting<T>wrapper, look up theJsonTypeInfofor it, and callJsonSerializer.Serialize— no downcast to a specific converter class.Serializeshortcut methods and the converter downcast branches are removed fromLightResultandLightResult<T>.ASP.NET Core MVC Integration
LightActionResult.WriteBodyAsyncandLightActionResult<T>.WriteBodyAsyncfollow the same wrapper pattern, matching the Minimal API changes.LightActionResultandLightActionResult<T>.Shared Base Classes
BaseLightResult<TResult>andBaseLightActionResult<TResult>keepLightResultsHttpWriteOptions?as the constructor parameter and stored property (it remains the user-facing type).ExecuteAsync/ExecuteResultAsyncresolves options once at the top: merge the storedOverrideOptionswith DI-resolved options (viaResolveLightResultOptions), then freeze toResolvedHttpWriteOptionsviaToResolvedHttpWriteOptions(). The singleResolvedHttpWriteOptionsis passed to bothSetHeadersandWriteBodyAsync.SetHeadersandWriteBodyAsyncsignatures change to acceptResolvedHttpWriteOptionsso they no longer resolve options themselves.DI / Module Registration
AddDefaultLightResultsHttpWriteJsonConvertersregisters the new converters (HttpResultForWritingJsonConverter,HttpResultForWritingJsonConverterFactory) instead of the currentHttpWriteResultJsonConverterandHttpWriteResultJsonConverterFactory. The metadata converters (HttpWriteMetadataObjectJsonConverter,HttpWriteMetadataValueJsonConverter) remain.LightResultsHttpWriteOptionsas a parameter because the new converters are stateless. UpdateModulein both Minimal APIs and MVC accordingly (theConfigure<LightResultsHttpWriteOptions>lambda for JSON options becomes simpler or unnecessary).Code Cleanup
HttpWriteResultJsonConverter,HttpWriteResultJsonConverter<T>, andHttpWriteResultJsonConverterFactoryare removed.Serializemethod overloads that acceptedLightResultsHttpWriteOptionsare removed.HttpResultForWriting/HttpResultForWriting<T>construction and serialization.Benchmarks
Technical Details
Intermediary Types
Place these in
src/Light.Results/Http/Writing/:ResolvedHttpWriteOptionsThis struct is a frozen snapshot of
LightResultsHttpWriteOptions, created once per request. It avoids repeatedly reading from the mutable options class during serialization. A public extension method provides the conversion:This is the single point of truth for converting mutable options to frozen options. It is called once at the top of
ExecuteAsync/ExecuteResultAsync(after resolving the finalLightResultsHttpWriteOptionsfrom DI or override) and by theToHttpResultForWritingextension methods.Wrapper Types:
HttpResultForWritingandHttpResultForWriting<T>Extension Methods
An
HttpResultForWritingExtensionsstatic class provides:These methods create
ResolvedHttpWriteOptionsfrom the suppliedLightResultsHttpWriteOptionsand return the wrapper struct.Converter Architecture
Place converters in
src/Light.Results/Http/Writing/Json/:The converters reuse the existing
SerializerExtensions.SerializeProblemDetailsAndMetadataandWriteMetadataPropertyAndValuehelpers. The key difference is that the converter reads options from the wrapper'sResolvedOptionsfield instead of from a constructor-injectedLightResultsHttpWriteOptionsinstance.Because
SerializeProblemDetailsAndMetadatacurrently acceptsLightResultsHttpWriteOptions, it needs a new overload (or refactor) to acceptResolvedHttpWriteOptions. Keep the existing overload if backwards compatibility for direct callers is desired, or replace it since the library is not published yet and breaking changes are allowed.ASP.NET Core Integration Changes
Base Class Changes
BaseLightResult<TResult>andBaseLightActionResult<TResult>keepLightResultsHttpWriteOptions?as the constructor parameter andOverrideOptionsproperty — this is the user-facing type that callers pass toToMinimalApiResult,ToMvcActionResult, etc.The change happens inside
ExecuteAsync/ExecuteResultAsync:LightResultsHttpWriteOptionsvia the existingResolveLightResultOptions(OverrideOptions)path (override ?? DI).ResolvedHttpWriteOptionsby callingToResolvedHttpWriteOptions()— once.ResolvedHttpWriteOptionsto bothSetHeadersandWriteBodyAsync.SetHeaderssignature changes to acceptResolvedHttpWriteOptionsinstead of reading options from theHttpContextinternally. It uses the struct's fields directly for status code, content type, and metadata header decisions.WriteBodyAsyncsignature changes to also receiveResolvedHttpWriteOptionsso subclasses can construct the envelope without any additional resolution.LightResult.WriteBodyAsync(Minimal APIs)The method receives the already-resolved
ResolvedHttpWriteOptionsfrom the base class and becomes:JsonSerializerOptions(existing logic viaResolveJsonSerializerOptions).HttpResultForWritingfrom the enriched result and the receivedResolvedHttpWriteOptions.JsonTypeInfoforHttpResultForWritingfrom the serializer options.JsonSerializer.Serialize(writer, wrapper, typeInfo).The same applies to
LightResult<T>.WriteBodyAsyncwithHttpResultForWriting<T>.LightActionResult.WriteBodyAsync(MVC)Identical pattern, using
ResolveMvcJsonSerializerOptionsinstead.Extension Methods (
ToMinimalApiResult,ToMvcActionResult)These public extension methods remain unchanged — they accept
LightResultsHttpWriteOptions?and pass it straight through to the constructor. No conversion happens here; freezing toResolvedHttpWriteOptionsis deferred toExecuteAsync/ExecuteResultAsync.Module Registration
AddDefaultLightResultsHttpWriteJsonConverterscurrently requiresLightResultsHttpWriteOptionsand creates converters with it:After the change:
The
AddDefaultLightResultsHttpWriteJsonConvertersmethod signature changes to no longer requireLightResultsHttpWriteOptions. This simplifies theModule.ConfigureMinimalApiJsonOptionsForLightResultsandModule.ConfigureMvcJsonOptionsForLightResultsmethods: they no longer need theConfigure<LightResultsHttpWriteOptions>lambda to inject options into the JSON setup.Non-Generic
ResultBody Writing Edge CaseWhen
Resultis valid and has no metadata to serialize, the current converter writes nothing at all (empty body). The new converter should preserve this behavior: if the result is valid and there is no metadata to write,HttpResultForWritingJsonConvertershould write nothing. This requires the ASP.NET Core layer to check before callingJsonSerializer.Serialize— or the converter can write nothing and the caller omits the content type (which is already handled bySetContentTypeFromResult). Note: the currentSetContentTypeFromResultalready skips setting a content type for this case. The converter should simply not be invoked when there is nothing to write — this check belongs inWriteBodyAsync.Performance Considerations
ResolvedHttpWriteOptionsis small (fits in a few machine words) — minimal copy overhead.ExecuteAsync/ExecuteResultAsync. The singleResolvedHttpWriteOptionsinstance is reused for headers and body writing.