15 minlesson

Problem Details & Calculated DTO Fields

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:

json
1// Endpoint A
2{ "error": "not found" }
3
4// Endpoint B
5{ "message": "Parcel does not exist", "code": 404 }
6
7// Endpoint C
8{ "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:

json
1{
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:

FieldPurposeRequired
typeURI identifying the problem categoryRecommended
titleShort human-readable summaryRecommended
statusHTTP status codeRecommended
detailSpecific explanation for this occurrenceOptional
instanceURI of the request that caused the errorOptional

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:

csharp
1var builder = WebApplication.CreateBuilder(args);
2
3builder.Services.AddProblemDetails();
4
5var app = builder.Build();
6
7app.UseStatusCodePages();
8
9app.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:

csharp
1app.MapGet("/api/parcels/{id:guid}", async (Guid id, ParcelTrackingDbContext context) =>
2{
3 var parcel = await context.Parcels
4 .Include(p => p.ShipperAddress)
5 .Include(p => p.RecipientAddress)
6 .FirstOrDefaultAsync(p => p.Id == id);
7
8 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 }
15
16 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:

csharp
1app.MapGet("/api/tracking/{trackingNumber}", async (
2 string trackingNumber,
3 ParcelTrackingDbContext context) =>
4{
5 var parcel = await context.Parcels
6 .Include(p => p.RecipientAddress)
7 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
8
9 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"] = trackingNumber
18 });
19 }
20
21 return Results.Ok(MapToTrackingResponse(parcel));
22});

The response includes the custom field alongside the standard ones:

json
1{
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:

csharp
1builder.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

csharp
1// Parcel not found by ID
2return Results.Problem(
3 detail: $"No parcel exists with ID '{id}'.",
4 title: "Parcel Not Found",
5 statusCode: StatusCodes.Status404NotFound);

400 Bad Request

csharp
1// Invalid tracking number format
2return Results.Problem(
3 detail: "Tracking number must follow the format 'PKG-YYYYMMDD-XXXXXX'.",
4 title: "Invalid Tracking Number",
5 statusCode: StatusCodes.Status400BadRequest);

409 Conflict

csharp
1// Attempting to deliver an already delivered parcel
2return 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:

csharp
1static 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:

csharp
1static 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:

csharp
1static ParcelDetailResponse MapToDetailResponse(Parcel parcel)
2{
3 return new ParcelDetailResponse
4 {
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 AddressResponse
12 {
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.Phone
21 },
22 RecipientAddress = new AddressResponse
23 {
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.Phone
32 },
33 CreatedAt = parcel.CreatedAt,
34 DeliveredAt = parcel.DeliveredAt,
35 DaysInTransit = CalculateDaysInTransit(parcel),
36 IsDelivered = parcel.Status == ParcelStatus.Delivered
37 };
38}

Mapping Entity to Public DTO

The public mapping omits sensitive data and uses only the recipient city and state:

csharp
1static TrackingResponse MapToTrackingResponse(Parcel parcel)
2{
3 return new TrackingResponse
4 {
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.Delivered
14 };
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:

csharp
1[Fact]
2public void DaysInTransit_WhenDelivered_ReturnsFixedCount()
3{
4 var parcel = new Parcel
5 {
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.Delivered
9 };
10
11 var result = CalculateDaysInTransit(parcel);
12
13 Assert.Equal(3, result);
14}
15
16[Fact]
17public void IsDelivered_WhenInTransit_ReturnsFalse()
18{
19 var parcel = new Parcel { Status = ParcelStatus.InTransit };
20
21 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:

csharp
1var builder = WebApplication.CreateBuilder(args);
2
3builder.Services.AddDbContext<ParcelTrackingDbContext>();
4builder.Services.AddProblemDetails();
5
6var app = builder.Build();
7
8app.UseStatusCodePages();
9
10// Internal endpoint
11app.MapGet("/api/parcels/{id:guid}", async (Guid id, ParcelTrackingDbContext context) =>
12{
13 var parcel = await context.Parcels
14 .Include(p => p.ShipperAddress)
15 .Include(p => p.RecipientAddress)
16 .FirstOrDefaultAsync(p => p.Id == id);
17
18 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 }
25
26 return Results.Ok(MapToDetailResponse(parcel));
27});
28
29// Public endpoint
30app.MapGet("/api/tracking/{trackingNumber}", async (
31 string trackingNumber,
32 ParcelTrackingDbContext context) =>
33{
34 var parcel = await context.Parcels
35 .Include(p => p.RecipientAddress)
36 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
37
38 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 }
45
46 return Results.Ok(MapToTrackingResponse(parcel));
47});
48
49app.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() and UseStatusCodePages()
  • 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.

Problem Details & Calculated DTO Fields - Anko Academy