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 Controllers

Use the Problem() method in controller actions:

csharp
1[HttpGet("{id:guid}")]
2public async Task<ActionResult<ParcelDto>> GetById(Guid id)
3{
4 var parcel = await _parcelService.GetByIdAsync(id);
5
6 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 }
13
14 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:

csharp
1[HttpGet("{trackingNumber}")]
2public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)
3{
4 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);
5
6 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"] = trackingNumber
15 });
16 }
17
18 return Ok(MapToTrackingResponse(tracking));
19}

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.

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:

csharp
1public class ParcelNotFoundException : Exception
2{
3 public Guid? ParcelId { get; }
4 public string? TrackingNumber { get; }
5
6 public ParcelNotFoundException(Guid id)
7 : base($"No parcel exists with ID '{id}'.")
8 {
9 ParcelId = id;
10 }
11
12 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:

csharp
1public class ParcelService : IParcelService
2{
3 private readonly ParcelTrackingDbContext _context;
4
5 public ParcelService(ParcelTrackingDbContext context)
6 {
7 _context = context;
8 }
9
10 public async Task<ParcelDto> GetByIdAsync(Guid id)
11 {
12 var parcel = await _context.Parcels
13 .Include(p => p.ShipperAddress)
14 .Include(p => p.RecipientAddress)
15 .Include(p => p.ContentItems)
16 .FirstOrDefaultAsync(p => p.Id == id);
17
18 if (parcel is null)
19 {
20 throw new ParcelNotFoundException(id);
21 }
22
23 return MapToParcelDto(parcel);
24 }
25
26 public async Task<ParcelTrackingDto> GetByTrackingNumberAsync(string trackingNumber)
27 {
28 var parcel = await _context.Parcels
29 .Include(p => p.RecipientAddress)
30 .Include(p => p.TrackingEvents)
31 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
32
33 if (parcel is null)
34 {
35 throw new ParcelNotFoundException(trackingNumber);
36 }
37
38 return MapToTrackingDto(parcel);
39 }
40
41 // ... mapping methods omitted for brevity
42}

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:

csharp
1[HttpGet("{id:guid}")]
2public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)
3{
4 try
5 {
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}
18
19[HttpGet("{trackingNumber}")]
20public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)
21{
22 try
23 {
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.TrackingNumber
37 });
38 }
39}

The controller's responsibility is now clear:

  1. Call the service method
  2. If successful, map the DTO to a response
  3. 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 -- ParcelNotFoundException is 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

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 application setup with service layer, custom exceptions, and Problem Details:

csharp
1var builder = WebApplication.CreateBuilder(args);
2
3builder.Services.AddDbContext<ParcelTrackingDbContext>();
4builder.Services.AddScoped<IParcelService, ParcelService>();
5builder.Services.AddProblemDetails();
6
7var app = builder.Build();
8
9app.UseStatusCodePages();
10
11app.MapControllers();
12
13app.Run();

Controller implementation:

csharp
1[ApiController]
2[Route("api/parcels")]
3public class ParcelsController : ControllerBase
4{
5 private readonly IParcelService _parcelService;
6
7 public ParcelsController(IParcelService parcelService)
8 {
9 _parcelService = parcelService;
10 }
11
12 // Internal endpoint
13 [HttpGet("{id:guid}")]
14 public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)
15 {
16 try
17 {
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}
31
32[ApiController]
33[Route("api/tracking")]
34public class TrackingController : ControllerBase
35{
36 private readonly IParcelService _parcelService;
37
38 public TrackingController(IParcelService parcelService)
39 {
40 _parcelService = parcelService;
41 }
42
43 // Public endpoint
44 [HttpGet("{trackingNumber}")]
45 public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)
46 {
47 try
48 {
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.TrackingNumber
62 });
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() and UseStatusCodePages()
  • 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.

Problem Details & Calculated DTO Fields - Anko Academy