diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj
new file mode 100644
index 000000000..30312a2a7
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/PizzaShop.Api.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs
new file mode 100644
index 000000000..f1d053b67
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api.Tests/ProblemDetailsResponseTests.cs
@@ -0,0 +1,72 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace PizzaShop.Api.Tests;
+
+public sealed class ProblemDetailsResponseTests(WebApplicationFactory factory)
+ : IClassFixture>
+{
+ [Fact]
+ public async Task KnownPizza_ReturnsStock()
+ {
+ var response = await factory.CreateClient()
+ .GetAsync("/api/pizzas/Margherita", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task UnknownPizza_Returns404WithErrorCodeInTitle()
+ {
+ var response = await factory.CreateClient()
+ .GetAsync("/api/pizzas/Hawaiian", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
+
+ using var problem = await ReadJsonAsync(response);
+ Assert.Equal("Pizza.NotFound", problem.RootElement.GetProperty("title").GetString());
+ Assert.Equal(404, problem.RootElement.GetProperty("status").GetInt32());
+ Assert.Equal("/api/pizzas/Hawaiian", problem.RootElement.GetProperty("instance").GetString());
+ Assert.True(problem.RootElement.TryGetProperty("traceId", out _));
+ }
+
+ [Fact]
+ public async Task SoldOutPizza_Returns409Problem()
+ {
+ var response = await factory.CreateClient().PostAsJsonAsync(
+ "/api/pizzas/Diavola/orders", new { quantity = 3 }, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
+
+ using var problem = await ReadJsonAsync(response);
+ Assert.Equal("Pizza.SoldOut", problem.RootElement.GetProperty("title").GetString());
+ }
+
+ [Fact]
+ public async Task InvalidQuantity_Returns400WithErrors()
+ {
+ var response = await factory.CreateClient().PostAsJsonAsync(
+ "/api/pizzas/Margherita/orders", new { quantity = 0 }, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ using var problem = await ReadJsonAsync(response);
+ Assert.True(problem.RootElement.GetProperty("errors").TryGetProperty("Quantity", out _));
+ }
+
+ [Fact]
+ public async Task UnknownRoute_Returns404ProblemFromStatusCodePages()
+ {
+ var response = await factory.CreateClient()
+ .GetAsync("/api/pizza/Margherita", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
+ }
+
+ private static async Task ReadJsonAsync(HttpResponseMessage response) =>
+ JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs
new file mode 100644
index 000000000..758c3efa0
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Kitchen.cs
@@ -0,0 +1,39 @@
+namespace PizzaShop.Api;
+
+public sealed record PizzaStock(string Name, int Left);
+
+public sealed record OrderConfirmation(string Pizza, int Quantity);
+
+public static class KitchenErrors
+{
+ public static Error NotFound(string pizza) =>
+ new("Pizza.NotFound", $"We don't make {pizza} pizza.", ErrorType.NotFound);
+
+ public static Error SoldOut(string pizza, int left, int requested) =>
+ new("Pizza.SoldOut", $"Only {left} {pizza} left, {requested} requested.", ErrorType.Conflict);
+}
+
+public static class Kitchen
+{
+ private static readonly Dictionary Stock = new()
+ {
+ ["Margherita"] = 10,
+ ["Diavola"] = 2
+ };
+
+ public static Result Check(string pizza) =>
+ Stock.TryGetValue(pizza, out var left)
+ ? new PizzaStock(pizza, left)
+ : KitchenErrors.NotFound(pizza);
+
+ public static Result Order(string pizza, int quantity)
+ {
+ if (!Stock.TryGetValue(pizza, out var left))
+ return KitchenErrors.NotFound(pizza);
+
+ if (quantity > left)
+ return KitchenErrors.SoldOut(pizza, left, quantity);
+
+ return new OrderConfirmation(pizza, quantity);
+ }
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs
new file mode 100644
index 000000000..95df03e68
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaEndpoints.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace PizzaShop.Api;
+
+public sealed record OrderRequest([property: Range(1, 20)] int Quantity);
+
+public static class PizzaEndpoints
+{
+ public static void MapPizzaEndpoints(this IEndpointRouteBuilder app)
+ {
+ var pizzas = app.MapGroup("/api/pizzas");
+
+ pizzas.MapGet("/{name}", (string name) =>
+ {
+ var result = Kitchen.Check(name);
+
+ return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
+ });
+
+ pizzas.MapPost("/{name}/orders", (string name, OrderRequest request) =>
+ {
+ var result = Kitchen.Order(name, request.Quantity);
+
+ return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
+ });
+ }
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj
new file mode 100644
index 000000000..a3a34b647
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http
new file mode 100644
index 000000000..8767c03cc
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/PizzaShop.Api.http
@@ -0,0 +1,28 @@
+GET http://localhost:5000/api/pizzas/Margherita
+Accept: application/json
+
+###
+
+GET http://localhost:5000/api/pizzas/Hawaiian
+Accept: application/json
+
+###
+
+POST http://localhost:5000/api/pizzas/Diavola/orders
+Content-Type: application/json
+Accept: application/json
+
+{ "quantity": 3 }
+
+###
+
+POST http://localhost:5000/api/pizzas/Margherita/orders
+Content-Type: application/json
+Accept: application/json
+
+{ "quantity": 0 }
+
+###
+
+GET http://localhost:5000/api/pizza/Margherita
+Accept: application/json
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs
new file mode 100644
index 000000000..87fb7b1e3
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Program.cs
@@ -0,0 +1,24 @@
+using PizzaShop.Api;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddProblemDetails(options =>
+{
+ options.CustomizeProblemDetails = context =>
+ {
+ context.ProblemDetails.Instance = context.HttpContext.Request.Path;
+
+ if (context.Exception is not null && builder.Environment.IsDevelopment())
+ context.ProblemDetails.Extensions["exception"] = context.Exception.Message;
+ };
+});
+builder.Services.AddValidation();
+
+var app = builder.Build();
+
+app.UseExceptionHandler();
+app.UseStatusCodePages();
+
+app.MapPizzaEndpoints();
+
+app.Run();
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..8a7a1d863
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5284",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7113;http://localhost:5284",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs
new file mode 100644
index 000000000..0a0607d35
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/Result.cs
@@ -0,0 +1,39 @@
+namespace PizzaShop.Api;
+
+public enum ErrorType
+{
+ NotFound,
+ Conflict
+}
+
+public sealed record Error(string Code, string Description, ErrorType Type);
+
+public class Result
+{
+ protected Result(Error? error) => Error = error;
+
+ public Error? Error { get; }
+ public bool IsSuccess => Error is null;
+
+ public static Result Success() => new(null);
+
+ public static implicit operator Result(Error error) => new(error);
+}
+
+public sealed class Result : Result
+{
+ private readonly TValue? _value;
+
+ private Result(TValue? value, Error? error)
+ : base(error) => _value = value;
+
+ public TValue Value => IsSuccess
+ ? _value!
+ : throw new InvalidOperationException("A failed result has no value.");
+
+ public static implicit operator Result(TValue value) =>
+ new(value, null);
+
+ public static implicit operator Result(Error error) =>
+ new(default, error);
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs
new file mode 100644
index 000000000..09b1a9657
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/ResultExtensions.cs
@@ -0,0 +1,19 @@
+namespace PizzaShop.Api;
+
+public static class ResultExtensions
+{
+ public static IResult ToProblem(this Result result)
+ {
+ var error = result.Error
+ ?? throw new InvalidOperationException("A successful result is not a problem.");
+
+ var statusCode = error.Type switch
+ {
+ ErrorType.NotFound => StatusCodes.Status404NotFound,
+ ErrorType.Conflict => StatusCodes.Status409Conflict,
+ _ => StatusCodes.Status500InternalServerError
+ };
+
+ return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
+ }
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/PizzaShop.Api/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln b/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln
new file mode 100644
index 000000000..9eb064eb2
--- /dev/null
+++ b/aspnetcore-webapi/ProblemDetailsInAspNetCore/ProblemDetails.sln
@@ -0,0 +1,48 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PizzaShop.Api", "PizzaShop.Api\PizzaShop.Api.csproj", "{F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PizzaShop.Api.Tests", "PizzaShop.Api.Tests\PizzaShop.Api.Tests.csproj", "{6AE0D900-D623-4F4A-B79A-AE7F11A90F07}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x64.Build.0 = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Debug|x86.Build.0 = Debug|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x64.ActiveCfg = Release|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x64.Build.0 = Release|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x86.ActiveCfg = Release|Any CPU
+ {F7EA5FA4-29B6-4AAC-A837-1D564EA6FCF6}.Release|x86.Build.0 = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x64.Build.0 = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Debug|x86.Build.0 = Debug|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x64.ActiveCfg = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x64.Build.0 = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x86.ActiveCfg = Release|Any CPU
+ {6AE0D900-D623-4F4A-B79A-AE7F11A90F07}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal