Problem Details & Calculated DTO Fields
When an API cannot fulfill a request, the error response matters just as much as the success response. RFC 7807 Problem Details provides a standardized format that clients can parse consistently. This presentation covers how to implement Problem Details in ASP.NET Core and how to design calculated fields that enrich your DTOs.
The Problem with Ad-Hoc Errors
Without a standard, every endpoint invents its own error format:
json1// Endpoint A2{ "error": "not found" }34// Endpoint B5{ "message": "Parcel does not exist", "code": 404 }67// Endpoint C8{ "errors": ["Invalid tracking number"] }
Clients must write custom parsing logic for each endpoint. When a new developer adds an endpoint, they create yet another format. This fragmentation makes error handling in client applications unreliable and expensive to maintain.
RFC 7807 Problem Details
RFC 7807 defines a standard JSON structure for HTTP API error responses:
json1{2 "type": "https://tools.ietf.org/html/rfc7807",3 "title": "Parcel Not Found",4 "status": 404,5 "detail": "No parcel exists with tracking number 'PKG-INVALID-123'.",6 "instance": "/api/tracking/PKG-INVALID-123"7}
Each field serves a specific purpose:
| Field | Purpose | Required |
|---|---|---|
type | URI identifying the problem category | Recommended |
title | Short human-readable summary | Recommended |
status | HTTP status code | Recommended |
detail | Specific explanation for this occurrence | Optional |
instance | URI of the request that caused the error | Optional |
The type field acts as a machine-readable error code. Clients can switch on type to determine the error category without parsing the detail string.
ASP.NET Core Built-In Support
ASP.NET Core provides first-class support for Problem Details through the ProblemDetails class and result helpers.
Enabling Problem Details Globally
Register Problem Details services in Program.cs:
csharp1var builder = WebApplication.CreateBuilder(args);23builder.Services.AddProblemDetails();45var app = builder.Build();67app.UseStatusCodePages();89app.Run();
With AddProblemDetails(), ASP.NET Core automatically generates Problem Details responses for unhandled exceptions and empty status code results. The UseStatusCodePages() middleware ensures that bare status codes (like a 404 from routing) also produce Problem Details.
Returning Problem Details from Endpoints
Use Results.Problem() in minimal API endpoints:
csharp1app.MapGet("/api/parcels/{id:guid}", async (Guid id, ParcelTrackingDbContext context) =>2{3 var parcel = await context.Parcels4 .Include(p => p.ShipperAddress)5 .Include(p => p.RecipientAddress)6 .FirstOrDefaultAsync(p => p.Id == id);78 if (parcel is null)9 {10 return Results.Problem(11 detail: $"No parcel exists with ID '{id}'.",12 title: "Parcel Not Found",13 statusCode: StatusCodes.Status404NotFound);14 }1516 return Results.Ok(MapToDetailResponse(parcel));17});
The Results.Problem() method creates a ProblemDetails object and serializes it with the application/problem+json content type. This content type tells clients that the response body follows the RFC 7807 format.
Customizing Problem Details
Adding Extension Fields
RFC 7807 allows extension members beyond the standard fields. Use a dictionary to add custom data:
csharp1app.MapGet("/api/tracking/{trackingNumber}", async (2 string trackingNumber,3 ParcelTrackingDbContext context) =>4{5 var parcel = await context.Parcels6 .Include(p => p.RecipientAddress)7 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);89 if (parcel is null)10 {11 return Results.Problem(12 detail: $"No parcel found with tracking number '{trackingNumber}'.",13 title: "Tracking Number Not Found",14 statusCode: StatusCodes.Status404NotFound,15 extensions: new Dictionary<string, object?>16 {17 ["trackingNumber"] = trackingNumber18 });19 }2021 return Results.Ok(MapToTrackingResponse(parcel));22});
The response includes the custom field alongside the standard ones:
json1{2 "type": "https://tools.ietf.org/html/rfc7807",3 "title": "Tracking Number Not Found",4 "status": 404,5 "detail": "No parcel found with tracking number 'PKG-INVALID-123'.",6 "trackingNumber": "PKG-INVALID-123"7}
Extension fields are useful for providing machine-readable context that clients can use for retry logic or user-facing messages.
Customizing the ProblemDetails Service
Configure default behavior through the ProblemDetailsOptions:
csharp1builder.Services.AddProblemDetails(options =>2{3 options.CustomizeProblemDetails = context =>4 {5 context.ProblemDetails.Instance =6 $"{context.HttpContext.Request.Method} " +7 $"{context.HttpContext.Request.Path}";8 };9});
This customization adds the HTTP method and path to every Problem Details response automatically, so individual endpoints do not need to set the instance field.
Problem Details for Different Error Scenarios
Different error cases map to different HTTP status codes and Problem Details titles:
404 Not Found
csharp1// Parcel not found by ID2return Results.Problem(3 detail: $"No parcel exists with ID '{id}'.",4 title: "Parcel Not Found",5 statusCode: StatusCodes.Status404NotFound);
400 Bad Request
csharp1// Invalid tracking number format2return Results.Problem(3 detail: "Tracking number must follow the format 'PKG-YYYYMMDD-XXXXXX'.",4 title: "Invalid Tracking Number",5 statusCode: StatusCodes.Status400BadRequest);
409 Conflict
csharp1// Attempting to deliver an already delivered parcel2return Results.Problem(3 detail: $"Parcel '{trackingNumber}' has already been delivered.",4 title: "Parcel Already Delivered",5 statusCode: StatusCodes.Status409Conflict);
Each error scenario uses a descriptive title for humans and a specific detail for debugging. The HTTP status code drives client behavior (retry, show error, redirect).
Designing Calculated DTO Fields
Calculated fields are values derived from stored data at response time. They simplify client logic by pre-computing common operations.
DaysInTransit Calculation
This field tells the consumer how many days the parcel has been (or was) in transit:
csharp1static int CalculateDaysInTransit(Parcel parcel)2{3 var endDate = parcel.DeliveredAt ?? DateTime.UtcNow;4 return (int)(endDate - parcel.CreatedAt).TotalDays;5}
The logic handles two cases:
- Parcel delivered -- calculate from creation to delivery date (fixed value)
- Parcel in transit -- calculate from creation to current UTC time (changes daily)
Using DateTime.UtcNow ensures consistent behavior across time zones. The cast to int truncates partial days, giving a whole-number count.
IsDelivered Boolean
A convenience field that saves clients from comparing status strings:
csharp1static bool IsDelivered(Parcel parcel)2{3 return parcel.Status == ParcelStatus.Delivered;4}
Without this field, clients must know the exact string value for "delivered" and handle case sensitivity. A boolean is unambiguous.
Mapping Entity to Internal DTO
The complete mapping method computes both calculated fields:
csharp1static ParcelDetailResponse MapToDetailResponse(Parcel parcel)2{3 return new ParcelDetailResponse4 {5 Id = parcel.Id,6 TrackingNumber = parcel.TrackingNumber,7 Status = parcel.Status.ToString(),8 Weight = parcel.Weight,9 WeightUnit = parcel.WeightUnit.ToString(),10 Description = parcel.Description,11 ShipperAddress = new AddressResponse12 {13 Id = parcel.ShipperAddress.Id,14 Street1 = parcel.ShipperAddress.Street1,15 City = parcel.ShipperAddress.City,16 State = parcel.ShipperAddress.State,17 PostalCode = parcel.ShipperAddress.PostalCode,18 CountryCode = parcel.ShipperAddress.CountryCode,19 ContactName = parcel.ShipperAddress.ContactName,20 Phone = parcel.ShipperAddress.Phone21 },22 RecipientAddress = new AddressResponse23 {24 Id = parcel.RecipientAddress.Id,25 Street1 = parcel.RecipientAddress.Street1,26 City = parcel.RecipientAddress.City,27 State = parcel.RecipientAddress.State,28 PostalCode = parcel.RecipientAddress.PostalCode,29 CountryCode = parcel.RecipientAddress.CountryCode,30 ContactName = parcel.RecipientAddress.ContactName,31 Phone = parcel.RecipientAddress.Phone32 },33 CreatedAt = parcel.CreatedAt,34 DeliveredAt = parcel.DeliveredAt,35 DaysInTransit = CalculateDaysInTransit(parcel),36 IsDelivered = parcel.Status == ParcelStatus.Delivered37 };38}
Mapping Entity to Public DTO
The public mapping omits sensitive data and uses only the recipient city and state:
csharp1static TrackingResponse MapToTrackingResponse(Parcel parcel)2{3 return new TrackingResponse4 {5 TrackingNumber = parcel.TrackingNumber,6 Status = parcel.Status.ToString(),7 RecipientCity = parcel.RecipientAddress.City,8 RecipientState = parcel.RecipientAddress.State,9 Weight = parcel.Weight,10 ShippedAt = parcel.CreatedAt,11 DeliveredAt = parcel.DeliveredAt,12 DaysInTransit = CalculateDaysInTransit(parcel),13 IsDelivered = parcel.Status == ParcelStatus.Delivered14 };15}
Both DTOs share the same CalculateDaysInTransit method. The calculated field logic is defined once and reused across mappings.
Testing Calculated Fields
Calculated fields are pure functions, making them straightforward to test:
csharp1[Fact]2public void DaysInTransit_WhenDelivered_ReturnsFixedCount()3{4 var parcel = new Parcel5 {6 CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),7 DeliveredAt = new DateTime(2026, 1, 4, 0, 0, 0, DateTimeKind.Utc),8 Status = ParcelStatus.Delivered9 };1011 var result = CalculateDaysInTransit(parcel);1213 Assert.Equal(3, result);14}1516[Fact]17public void IsDelivered_WhenInTransit_ReturnsFalse()18{19 var parcel = new Parcel { Status = ParcelStatus.InTransit };2021 Assert.False(parcel.Status == ParcelStatus.Delivered);22}
Since CalculateDaysInTransit is a static method with no external dependencies, unit tests are simple and fast.
Putting It All Together
Here is the complete endpoint registration with Problem Details and calculated fields:
csharp1var builder = WebApplication.CreateBuilder(args);23builder.Services.AddDbContext<ParcelTrackingDbContext>();4builder.Services.AddProblemDetails();56var app = builder.Build();78app.UseStatusCodePages();910// Internal endpoint11app.MapGet("/api/parcels/{id:guid}", async (Guid id, ParcelTrackingDbContext context) =>12{13 var parcel = await context.Parcels14 .Include(p => p.ShipperAddress)15 .Include(p => p.RecipientAddress)16 .FirstOrDefaultAsync(p => p.Id == id);1718 if (parcel is null)19 {20 return Results.Problem(21 detail: $"No parcel exists with ID '{id}'.",22 title: "Parcel Not Found",23 statusCode: StatusCodes.Status404NotFound);24 }2526 return Results.Ok(MapToDetailResponse(parcel));27});2829// Public endpoint30app.MapGet("/api/tracking/{trackingNumber}", async (31 string trackingNumber,32 ParcelTrackingDbContext context) =>33{34 var parcel = await context.Parcels35 .Include(p => p.RecipientAddress)36 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);3738 if (parcel is null)39 {40 return Results.Problem(41 detail: $"No parcel found with tracking number '{trackingNumber}'.",42 title: "Tracking Number Not Found",43 statusCode: StatusCodes.Status404NotFound);44 }4546 return Results.Ok(MapToTrackingResponse(parcel));47});4849app.Run();
Both endpoints follow the same pattern: query with appropriate includes, check for null, return Problem Details or the mapped DTO. The consistent structure makes the API predictable for both internal and external consumers.
Summary
In this presentation, you learned how to:
- Enable Problem Details globally with
AddProblemDetails()andUseStatusCodePages() - Return standardized error responses using
Results.Problem() - Add extension fields to Problem Details for machine-readable context
- Design calculated DTO fields (
DaysInTransit,IsDelivered) that simplify client logic - Handle the delivered vs. in-transit distinction in date calculations
- Write testable mapping methods that compute derived values
Next, test your understanding of parcel retrieval, Problem Details, and calculated fields in the topic quiz.