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 Controllers
Use the Problem() method in controller actions:
csharp1[HttpGet("{id:guid}")]2public async Task<ActionResult<ParcelDto>> GetById(Guid id)3{4 var parcel = await _parcelService.GetByIdAsync(id);56 if (parcel is null)7 {8 return Problem(9 detail: $"No parcel exists with ID '{id}'.",10 title: "Parcel Not Found",11 statusCode: StatusCodes.Status404NotFound);12 }1314 return Ok(parcel);15}
The 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:
csharp1[HttpGet("{trackingNumber}")]2public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)3{4 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);56 if (tracking is null)7 {8 return Problem(9 detail: $"No parcel found with tracking number '{trackingNumber}'.",10 title: "Tracking Number Not Found",11 statusCode: StatusCodes.Status404NotFound,12 extensions: new Dictionary<string, object?>13 {14 ["trackingNumber"] = trackingNumber15 });16 }1718 return Ok(MapToTrackingResponse(tracking));19}
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.
Service Layer Exception Pattern
With the service layer in place, we can improve error handling by throwing domain-specific exceptions from the service and catching them in the controller. This separates business logic errors from HTTP concerns.
Defining Custom Exceptions
Create exception types for common domain errors:
csharp1public class ParcelNotFoundException : Exception2{3 public Guid? ParcelId { get; }4 public string? TrackingNumber { get; }56 public ParcelNotFoundException(Guid id)7 : base($"No parcel exists with ID '{id}'.")8 {9 ParcelId = id;10 }1112 public ParcelNotFoundException(string trackingNumber)13 : base($"No parcel found with tracking number '{trackingNumber}'.")14 {15 TrackingNumber = trackingNumber;16 }17}
The exception includes the identifier that was not found, allowing the controller to include it in the Problem Details response or logs.
Throwing Exceptions from the Service
Update the service methods to throw instead of returning null:
csharp1public class ParcelService : IParcelService2{3 private readonly ParcelTrackingDbContext _context;45 public ParcelService(ParcelTrackingDbContext context)6 {7 _context = context;8 }910 public async Task<ParcelDto> GetByIdAsync(Guid id)11 {12 var parcel = await _context.Parcels13 .Include(p => p.ShipperAddress)14 .Include(p => p.RecipientAddress)15 .Include(p => p.ContentItems)16 .FirstOrDefaultAsync(p => p.Id == id);1718 if (parcel is null)19 {20 throw new ParcelNotFoundException(id);21 }2223 return MapToParcelDto(parcel);24 }2526 public async Task<ParcelTrackingDto> GetByTrackingNumberAsync(string trackingNumber)27 {28 var parcel = await _context.Parcels29 .Include(p => p.RecipientAddress)30 .Include(p => p.TrackingEvents)31 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);3233 if (parcel is null)34 {35 throw new ParcelNotFoundException(trackingNumber);36 }3738 return MapToTrackingDto(parcel);39 }4041 // ... mapping methods omitted for brevity42}
Notice the return types have changed:
- Before:
Task<ParcelDto?>(nullable) - After:
Task<ParcelDto>(non-nullable)
The service contract now guarantees that if the method returns, the parcel exists. Callers do not need to check for null.
Catching Exceptions in the Controller
Update the controller actions to catch and convert exceptions to Problem Details:
csharp1[HttpGet("{id:guid}")]2public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)3{4 try5 {6 var parcel = await _parcelService.GetByIdAsync(id);7 var response = MapToDetailResponse(parcel);8 return Ok(response);9 }10 catch (ParcelNotFoundException ex)11 {12 return Problem(13 detail: ex.Message,14 title: "Parcel Not Found",15 statusCode: StatusCodes.Status404NotFound);16 }17}1819[HttpGet("{trackingNumber}")]20public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)21{22 try23 {24 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);25 var response = MapToTrackingResponse(tracking);26 return Ok(response);27 }28 catch (ParcelNotFoundException ex)29 {30 return Problem(31 detail: ex.Message,32 title: "Tracking Number Not Found",33 statusCode: StatusCodes.Status404NotFound,34 extensions: new Dictionary<string, object?>35 {36 ["trackingNumber"] = ex.TrackingNumber37 });38 }39}
The controller's responsibility is now clear:
- Call the service method
- If successful, map the DTO to a response
- If an exception is thrown, catch it and return the appropriate Problem Details
Benefits of the Exception Pattern
This pattern provides several advantages:
- Service methods have clean signatures -- no nullable return types or out parameters
- Domain errors are explicit --
ParcelNotFoundExceptionis more specific than checking for null - Error context is preserved -- the exception carries the ID or tracking number for logging
- Controllers stay thin -- error handling logic is centralized in exception handlers
- Easy to add more exceptions -- new domain errors follow the same pattern
Avoiding Exceptions for Control Flow
A common criticism of exceptions is "exceptions should be exceptional." In this case, a parcel not being found is an error condition, not normal control flow. The endpoint contract says "give me a parcel by ID," and if that cannot be fulfilled, it is an exceptional case.
If you are retrieving parcels in a loop and expect some to be missing, use a method like TryGetByIdAsync that returns Task<(bool success, ParcelDto? parcel)> instead.
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 application setup with service layer, custom exceptions, and Problem Details:
csharp1var builder = WebApplication.CreateBuilder(args);23builder.Services.AddDbContext<ParcelTrackingDbContext>();4builder.Services.AddScoped<IParcelService, ParcelService>();5builder.Services.AddProblemDetails();67var app = builder.Build();89app.UseStatusCodePages();1011app.MapControllers();1213app.Run();
Controller implementation:
csharp1[ApiController]2[Route("api/parcels")]3public class ParcelsController : ControllerBase4{5 private readonly IParcelService _parcelService;67 public ParcelsController(IParcelService parcelService)8 {9 _parcelService = parcelService;10 }1112 // Internal endpoint13 [HttpGet("{id:guid}")]14 public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)15 {16 try17 {18 var parcel = await _parcelService.GetByIdAsync(id);19 var response = MapToDetailResponse(parcel);20 return Ok(response);21 }22 catch (ParcelNotFoundException ex)23 {24 return Problem(25 detail: ex.Message,26 title: "Parcel Not Found",27 statusCode: StatusCodes.Status404NotFound);28 }29 }30}3132[ApiController]33[Route("api/tracking")]34public class TrackingController : ControllerBase35{36 private readonly IParcelService _parcelService;3738 public TrackingController(IParcelService parcelService)39 {40 _parcelService = parcelService;41 }4243 // Public endpoint44 [HttpGet("{trackingNumber}")]45 public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)46 {47 try48 {49 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);50 var response = MapToTrackingResponse(tracking);51 return Ok(response);52 }53 catch (ParcelNotFoundException ex)54 {55 return Problem(56 detail: ex.Message,57 title: "Tracking Number Not Found",58 statusCode: StatusCodes.Status404NotFound,59 extensions: new Dictionary<string, object?>60 {61 ["trackingNumber"] = ex.TrackingNumber62 });63 }64 }65}
Both controller actions follow the same pattern: call the service method, map the DTO to a response, and catch domain exceptions to return Problem Details. The service layer handles data access and domain logic, while the controller handles HTTP concerns.
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
- Define custom exception types (
ParcelNotFoundException) for domain errors - Throw exceptions from the service layer with clean, non-nullable return types
- Catch domain exceptions in controllers and convert them to Problem Details
- Preserve error context (IDs, tracking numbers) in exception properties
- Design calculated DTO fields (
DaysInTransit,IsDelivered) that simplify client logic - Separate concerns: service layer throws domain exceptions, controller converts them to HTTP responses
Next, test your understanding of parcel retrieval, service layer patterns, Problem Details, and custom exceptions in the topic quiz.