diff --git a/Directory.Packages.props b/Directory.Packages.props
index 632e6d8..0530aef 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,12 +7,13 @@
5.9.0
1.68.4
1.0.0-prerelease.44
- 2.0.0-prerelease.9
+ 2.0.0-prerelease.10
+
diff --git a/README.md b/README.md
index 59ea8a6..6186414 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,8 @@ public readonly partial record struct EmailAddress
var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com"));
```
-See [ZodSharp Validation](docs/ZodSharp-Validation.md) and the `samples/ValueObjects.ZodSharpSample` project.
+See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `samples/ValueObjects.ZodSharpSample` project, and
+the `src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures).
## How it works
diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md
index 2f6bde9..0459c1a 100644
--- a/docs/Getting-Started.md
+++ b/docs/Getting-Started.md
@@ -173,6 +173,11 @@ constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not-
`ZodException`. Use `ZodSchemaMode.InsteadOfHooks` on the attribute to run the schema instead of the
`OnValidate` hook.
+In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem
+Details responses — combine `ValueObjectDeserializationMode.Strict` with
+`AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return
+`HttpValidationProblemDetails` automatically. See the `src/ZodSharp.AspNetCoreSample` project.
+
See `ZodSharp-Validation.md` and the `samples/` folder.
## Next steps
diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md
index b4fe34f..49cd4d2 100644
--- a/docs/ZodSharp-Validation.md
+++ b/docs/ZodSharp-Validation.md
@@ -216,6 +216,84 @@ foreach (var error in result.Errors)
Use `Parse` / `GetValueOrThrow()` to throw a `ZodException` on failure instead of inspecting the
result.
+### ASP.NET Core Problem Details
+
+In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard
+`HttpValidationProblemDetails` responses. This covers strict deserialization of value objects
+(`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a
+`ZodException`.
+
+Wire the handler into the pipeline:
+
+```csharp
+builder.Services.AddZodSharpProblemDetails();
+builder.Services.AddProblemDetails();
+
+var app = builder.Build();
+app.UseExceptionHandler();
+```
+
+Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw
+during model binding:
+
+```csharp
+[Scalar(
+ ZodSchemaMode = ZodSchemaMode.InsteadOfHooks,
+ DeserializationMode = ValueObjectDeserializationMode.Strict)]
+[ZodSchema]
+public readonly partial record struct EmailAddress
+{
+ [Required, EmailAddress]
+ public string Value { get; }
+}
+
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory()));
+```
+
+A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues
+in the `issues` extension.
+
+Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`:
+
+```csharp
+public static class ConcurrentErrorType
+{
+ public static readonly ErrorType SaveFailed = new(
+ Code: "aggregate_save_failed",
+ Description: "The order could not be saved because it was modified concurrently.",
+ HttpStatus: StatusCodes.Status409Conflict,
+ MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save")
+ {
+ Parameters = ["OrderId", "AggregateType"]
+ };
+}
+
+ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
+```
+
+Throwing a `ZodException` with that code and parameters yields a `409 Conflict` whose message is
+formatted from the error's parameters:
+
+```csharp
+throw new ZodException([
+ ValidationError.Create(
+ "aggregate_save_failed",
+ "The order could not be saved.",
+ path: [],
+ parameters: new Dictionary
+ {
+ ["OrderId"] = orderId,
+ ["AggregateType"] = "Order",
+ }),
+]);
+```
+
+The bundled `ZODSASP001` analyzer flags `MessageFormat` placeholders missing from `Parameters` at
+compile time. See the
+[ASP.NET Core integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide and the
+`src/ZodSharp.AspNetCoreSample` project.
+
## JSON Schema export
Export a schema to JSON Schema (Draft 2020-12) for cross-platform sharing with TypeScript Zod:
diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx
index aef2e03..93c1977 100644
--- a/src/ValueObjects.slnx
+++ b/src/ValueObjects.slnx
@@ -17,6 +17,7 @@
+
diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
index 4a8f631..90baff7 100644
--- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
+++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
@@ -27,8 +27,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
// short-circuits instead of re-executing every value-object transform (see PreCompilationMarker).
#pragma warning disable RSEXPERIMENTAL007 // Pre-compilation source output is intentionally used to stabilize the incremental cache.
context.RegisterPreCompilationSourceOutput(
- Common.PreCompilationMarker.Provider(context),
- static (spc, source) => spc.AddSource(Common.PreCompilationMarker.HintName, source)
+ PreCompilationMarker.Provider(context),
+ static (spc, source) => spc.AddSource(PreCompilationMarker.HintName, source)
);
#pragma warning restore RSEXPERIMENTAL007
diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
index 290218d..456d3f4 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
@@ -16,6 +16,7 @@ ImmutableArray attributes
if (!assemblyDefaults.Exists)
return typeOptions;
+ // Merge assembly defaults into the type options, but only for properties that are not explicitly set on the type.
return typeOptions with
{
GenerateJsonConverter = MergeBool(
@@ -77,6 +78,7 @@ ImmutableArray attributes
if (!assemblyDefaults.Exists)
return typeOptions;
+ // Merge assembly defaults into the type options, but only for properties that are not explicitly set on the type.
return typeOptions with
{
GenerateJsonConverter = MergeBool(
diff --git a/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs b/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs
new file mode 100644
index 0000000..3629aa5
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs
@@ -0,0 +1,24 @@
+using ZodSharp.AspNetCore;
+
+namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample;
+
+///
+/// Error types registered in that map validation error codes to
+/// HTTP status codes and formatted messages. The bundled ZODSASP001 analyzer verifies that
+/// every placeholder is declared in .
+///
+static class ConcurrentErrorType
+{
+ ///
+ /// Maps the aggregate_save_failed code to a 409 Conflict response.
+ ///
+ public static readonly ErrorType SaveFailed = new(
+ Code: "aggregate_save_failed",
+ Description: "The order could not be saved because it was modified concurrently.",
+ HttpStatus: StatusCodes.Status409Conflict,
+ MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save"
+ )
+ {
+ Parameters = ["OrderId", "AggregateType"],
+ };
+}
diff --git a/src/src/ZodSharp.AspNetCoreSample/Models.cs b/src/src/ZodSharp.AspNetCoreSample/Models.cs
new file mode 100644
index 0000000..6ef3f68
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/Models.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+using Purview.ValueObjects.Serialization;
+using ZodSharp;
+
+namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample;
+
+///
+/// A scalar value object validated by the ZodSharp-generated schema and deserialized in
+/// mode, so invalid JSON input throws a
+/// ZodException that the registered exception handler converts to Problem Details.
+///
+[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)]
+[ZodSchema]
+readonly partial record struct EmailAddress
+{
+ [Required, EmailAddress, StringLength(254)]
+ public string Value { get; }
+}
+
+///
+/// A scalar value object validated by the ZodSharp-generated schema and deserialized strictly.
+///
+[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)]
+[ZodSchema]
+readonly partial record struct OrderQuantity
+{
+ [Range(1, 100)]
+ public int Value { get; }
+}
+
+///
+/// The request DTO bound by POST /orders. Its value object members are deserialized strictly.
+///
+sealed record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity);
diff --git a/src/src/ZodSharp.AspNetCoreSample/Program.cs b/src/src/ZodSharp.AspNetCoreSample/Program.cs
new file mode 100644
index 0000000..14c05c5
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/Program.cs
@@ -0,0 +1,76 @@
+using Purview.ValueObjects.Serialization;
+using ZodSharp.AspNetCore;
+using ZodSharp.Core;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Register the ScalarJsonConverterFactory so value objects serialize as their scalar value and
+// deserialize through the configured ValueObjectDeserializationMode (Strict here).
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())
+);
+
+// Registers ZodExceptionHandler as an IExceptionHandler. Requires UseExceptionHandler() in the
+// pipeline (added below), otherwise the handler is never invoked.
+builder.Services.AddZodSharpProblemDetails();
+
+// Required by the parameterless UseExceptionHandler() for its default fallback response when no
+// registered handler matches the exception.
+builder.Services.AddProblemDetails();
+
+// Map error codes to HTTP statuses and formatted messages.
+ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
+
+var app = builder.Build();
+
+app.UseExceptionHandler();
+
+// Binds a request containing strict scalar value objects. Invalid JSON (for example an invalid
+// email or a quantity outside 1-100) throws a ZodException during deserialization, which the
+// exception handler turns into a 400 HttpValidationProblemDetails response.
+app.MapPost(
+ "/orders",
+ (PlaceOrderRequest request) =>
+ Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value })
+);
+
+// Throws a ZodException carrying a registered error code. The handler resolves the ErrorType from
+// the registry and returns a 409 Conflict response whose message is formatted from the error's
+// parameters.
+app.MapPost("/orders/{orderId}/confirm", ConfirmOrder);
+
+static IResult ConfirmOrder(string orderId) =>
+ throw new ZodException([
+ ValidationError.Create(
+ "aggregate_save_failed",
+ "The order could not be saved.",
+ path: [],
+ parameters: new Dictionary { ["OrderId"] = orderId, ["AggregateType"] = "Order" }
+ ),
+ ]);
+
+// Demonstrates on-demand mapping: a ZodException caught in the handler is converted explicitly
+// with ErrorType resolution, without relying on the exception-handling middleware.
+app.MapGet(
+ "/orders/{orderId}/email",
+ (string orderId, string email) =>
+ {
+ try
+ {
+ var parsed = EmailAddress.Create(email);
+ return Results.Ok(new { Email = parsed.Value, OrderId = orderId });
+ }
+ catch (ZodException ex)
+ {
+ var problem = ex.ToHttpValidationProblemDetails(ErrorTypeRegistry.Default);
+ return Results.Problem(
+ problem.Detail,
+ statusCode: problem.Status,
+ title: problem.Title,
+ extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }
+ );
+ }
+ }
+);
+
+app.Run();
diff --git a/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json b/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json
new file mode 100644
index 0000000..17a974f
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "ZodSharp.AspNetCoreSample": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:61096;http://localhost:61097"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/src/ZodSharp.AspNetCoreSample/README.md b/src/src/ZodSharp.AspNetCoreSample/README.md
new file mode 100644
index 0000000..ad55fbe
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/README.md
@@ -0,0 +1,60 @@
+# Purview.ValueObjects + ZodSharp ASP.NET Core Sample
+
+Shows how value objects validated by [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp)
+surface as standard ASP.NET Core `Problem Details` responses using
+[Purview.ZodSharp.AspNetCore](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore).
+
+## Run
+
+```text
+dotnet run --project src/src/ZodSharp.AspNetCoreSample
+```
+
+## What it shows
+
+- **Strict deserialization of value objects** — `EmailAddress` and `OrderQuantity` are annotated with
+ `[Scalar]` + `[ZodSchema]` and `ValueObjectDeserializationMode.Strict`. Their generated `Create` runs
+ the ZodSharp-generated schema, so deserializing an invalid value throws a `ZodException`.
+- **Automatic exception handling** — `AddZodSharpProblemDetails()` registers `ZodExceptionHandler` and
+ `app.UseExceptionHandler()` catches the thrown `ZodException`, converting it to a
+ `HttpValidationProblemDetails` response with the structured issues in the `issues` extension.
+- **Mapping error types to status codes** — `ConcurrentErrorType.SaveFailed` is registered in
+ `ErrorTypeRegistry.Default`; a `ZodException` carrying the `aggregate_save_failed` code is returned as
+ a `409 Conflict` with a message formatted from the error's parameters.
+- **On-demand mapping** — `ZodException.ToHttpValidationProblemDetails(...)` converts an exception
+ explicitly, without the exception-handling middleware.
+- **Compile-time placeholder checking** — the `ZODSASP001` analyzer (bundled with the package) verifies
+ that every `MessageFormat` placeholder is declared in `ErrorType.Parameters`.
+
+## Try it
+
+Place an order with a valid body:
+
+```bash
+curl -i -X POST http://localhost:5000/orders \
+ -H "Content-Type: application/json" \
+ -d '{"customerEmail":"demo@example.com","quantity":2}'
+```
+
+Send an invalid email — the strict value object throws and the handler returns a 400 Problem Details
+payload:
+
+```bash
+curl -i -X POST http://localhost:5000/orders \
+ -H "Content-Type: application/json" \
+ -d '{"customerEmail":"not-an-email","quantity":2}'
+```
+
+Simulate an optimistic-concurrency failure (returns `409 Conflict` with a formatted message):
+
+```bash
+curl -i -X POST http://localhost:5000/orders/ord-123/confirm
+```
+
+Validate an email on demand:
+
+```bash
+curl -i "http://localhost:5000/orders/ord-123/email?email=not-an-email"
+```
+
+See `docs/ZodSharp-Validation.md` for the full guide.
\ No newline at end of file
diff --git a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj
new file mode 100644
index 0000000..d113306
--- /dev/null
+++ b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj
@@ -0,0 +1,30 @@
+
+
+ false
+
+ $(NoWarn);CA1506
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs
index 32286ad..5d1b58b 100644
--- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs
@@ -1,5 +1,3 @@
-using System.Reflection;
-
namespace Purview.ValueObjects.SourceGenerator.Generators;
///