15 minlesson

Building Retrieval Endpoints

Building Retrieval Endpoints

With the domain model and registration endpoint in place, we now build two retrieval endpoints: one for internal use (by parcel ID) and one for public tracking (by tracking number). Each returns a different DTO shaped for its audience.

The Parcel Entity Recap

Our parcel entity from the previous topics looks like this:

csharp
1public class Parcel
2{
3 public Guid Id { get; set; }
4 public string TrackingNumber { get; set; } = string.Empty;
5 public string? Description { get; set; }
6 public ServiceType ServiceType { get; set; }
7 public ParcelStatus Status { get; set; }
8
9 public Guid ShipperAddressId { get; set; }
10 public Address ShipperAddress { get; set; } = null!;
11
12 public Guid RecipientAddressId { get; set; }
13 public Address RecipientAddress { get; set; } = null!;
14
15 public decimal Weight { get; set; }
16 public WeightUnit WeightUnit { get; set; }
17 public decimal Length { get; set; }
18 public decimal Width { get; set; }
19 public decimal Height { get; set; }
20 public DimensionUnit DimensionUnit { get; set; }
21
22 public decimal DeclaredValue { get; set; }
23 public string Currency { get; set; } = "USD";
24
25 public DateTimeOffset? EstimatedDeliveryDate { get; set; }
26 public DateTimeOffset? ActualDeliveryDate { get; set; }
27
28 public int DeliveryAttempts { get; set; }
29
30 public DateTimeOffset CreatedAt { get; set; }
31 public DateTimeOffset UpdatedAt { get; set; }
32
33 public ICollection<TrackingEvent> TrackingEvents { get; set; } = new List<TrackingEvent>();
34 public DeliveryConfirmation? DeliveryConfirmation { get; set; }
35}
36
37public enum ParcelStatus
38{
39 LabelCreated,
40 PickedUp,
41 InTransit,
42 OutForDelivery,
43 Delivered,
44 Exception,
45 Returned
46}

Both retrieval endpoints query the same Parcels table but project the results into different response shapes.

Defining the Internal Response DTO

The internal DTO exposes full parcel details including database IDs and both addresses:

csharp
1public class ParcelDetailResponse
2{
3 public Guid Id { get; set; }
4 public string TrackingNumber { get; set; } = string.Empty;
5 public string Status { get; set; } = string.Empty;
6 public decimal Weight { get; set; }
7 public string WeightUnit { get; set; } = string.Empty;
8 public string Description { get; set; } = string.Empty;
9
10 public AddressResponse ShipperAddress { get; set; } = null!;
11 public AddressResponse RecipientAddress { get; set; } = null!;
12 public List<ContentItemResponse> ContentItems { get; set; } = new();
13
14 public DateTime CreatedAt { get; set; }
15 public DateTime? DeliveredAt { get; set; }
16 public int DaysInTransit { get; set; }
17 public bool IsDelivered { get; set; }
18}
19
20public class ContentItemResponse
21{
22 public string HsCode { get; set; } = string.Empty;
23 public string Description { get; set; } = string.Empty;
24 public int Quantity { get; set; }
25 public decimal UnitValue { get; set; }
26 public string Currency { get; set; } = string.Empty;
27 public decimal Weight { get; set; }
28 public string WeightUnit { get; set; } = string.Empty;
29 public string CountryOfOrigin { get; set; } = string.Empty;
30}
31
32public class AddressResponse
33{
34 public Guid Id { get; set; }
35 public string Street1 { get; set; } = string.Empty;
36 public string City { get; set; } = string.Empty;
37 public string State { get; set; } = string.Empty;
38 public string PostalCode { get; set; } = string.Empty;
39 public string CountryCode { get; set; } = string.Empty;
40 public string? ContactName { get; set; }
41 public string? Phone { get; set; }
42}

This DTO includes everything the operations team needs: IDs for cross-referencing, full addresses with contact details, content item declarations with HS codes, and calculated transit information. The ContentItems list is essential for customs processing and compliance verification.

Defining the Public Tracking DTO

The public DTO strips out internal identifiers and sensitive contact information:

csharp
1public class TrackingResponse
2{
3 public string TrackingNumber { get; set; } = string.Empty;
4 public string Status { get; set; } = string.Empty;
5 public string RecipientCity { get; set; } = string.Empty;
6 public string RecipientState { get; set; } = string.Empty;
7 public decimal Weight { get; set; }
8 public DateTime ShippedAt { get; set; }
9 public DateTime? DeliveredAt { get; set; }
10 public int DaysInTransit { get; set; }
11 public bool IsDelivered { get; set; }
12}

No database Id, no shipper details, no full addresses, and no content items. A customer sees their tracking number, current status, approximate destination, and how long the parcel has been in transit. Content items are excluded from the public endpoint because they contain commercially sensitive information (declared values, supplier countries, HS codes) that should not be visible to anyone other than the shipper and carrier.

Implementing GET by Parcel ID

The internal endpoint retrieves a parcel by its database ID. We use Include() to eager-load both addresses:

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 .Include(p => p.ContentItems)
7 .FirstOrDefaultAsync(p => p.Id == id);
8
9 if (parcel is null)
10 {
11 return Results.Problem(
12 detail: $"No parcel exists with ID '{id}'.",
13 title: "Parcel Not Found",
14 statusCode: StatusCodes.Status404NotFound);
15 }
16
17 var response = MapToDetailResponse(parcel);
18 return Results.Ok(response);
19});

Key decisions in this implementation:

  • {id:guid} route constraint -- ASP.NET Core validates the parameter is a valid GUID before the handler runs, returning 400 for malformed IDs
  • FirstOrDefaultAsync over FindAsync -- FindAsync does not support Include(), so we use FirstOrDefaultAsync to load related data in a single query
  • Null check with Problem Details -- a clean 404 response when the parcel does not exist

The Mapping Method

The mapping method converts the entity to the internal DTO and computes the 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 = MapToAddressResponse(parcel.ShipperAddress),
12 RecipientAddress = MapToAddressResponse(parcel.RecipientAddress),
13 ContentItems = parcel.ContentItems.Select(ci => new ContentItemResponse
14 {
15 HsCode = ci.HsCode,
16 Description = ci.Description,
17 Quantity = ci.Quantity,
18 UnitValue = ci.UnitValue,
19 Currency = ci.Currency,
20 Weight = ci.Weight,
21 WeightUnit = ci.WeightUnit.ToString(),
22 CountryOfOrigin = ci.CountryOfOrigin
23 }).ToList(),
24 CreatedAt = parcel.CreatedAt,
25 DeliveredAt = parcel.DeliveredAt,
26 DaysInTransit = CalculateDaysInTransit(parcel),
27 IsDelivered = parcel.Status == ParcelStatus.Delivered
28 };
29}
30
31static int CalculateDaysInTransit(Parcel parcel)
32{
33 var endDate = parcel.DeliveredAt ?? DateTime.UtcNow;
34 return (int)(endDate - parcel.CreatedAt).TotalDays;
35}
36
37static AddressResponse MapToAddressResponse(Address address)
38{
39 return new AddressResponse
40 {
41 Id = address.Id,
42 Street1 = address.Street1,
43 City = address.City,
44 State = address.State,
45 PostalCode = address.PostalCode,
46 CountryCode = address.CountryCode,
47 ContactName = address.ContactName,
48 Phone = address.Phone
49 };
50}

Notice CalculateDaysInTransit uses the delivery date if the parcel has been delivered, or the current UTC time if it is still in transit. This ensures the field is accurate regardless of delivery status.

Implementing GET by Tracking Number

The public endpoint retrieves a parcel by its tracking number string:

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 }
16
17 var response = MapToTrackingResponse(parcel);
18 return Results.Ok(response);
19});

Differences from the internal endpoint:

  • Route uses trackingNumber string -- not a GUID, since tracking numbers are human-readable identifiers like "PKG-20260215-A1B2C3"
  • Only includes RecipientAddress -- the public response does not expose shipper information, so we skip that Include()
  • Different mapping method -- produces the simplified TrackingResponse

The tracking response mapping:

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}

Eager Loading Strategy

Choosing what to include depends entirely on what the DTO needs:

csharp
1// Internal endpoint - needs both addresses and content items
2context.Parcels
3 .Include(p => p.ShipperAddress)
4 .Include(p => p.RecipientAddress)
5 .Include(p => p.ContentItems)
6 .FirstOrDefaultAsync(p => p.Id == id);
7
8// Public endpoint - only needs recipient address
9context.Parcels
10 .Include(p => p.RecipientAddress)
11 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);

Every Include() adds a JOIN to the generated SQL. Including data you do not use wastes database resources and network bandwidth. Match your includes to your DTO requirements.

The generated SQL for the internal endpoint:

sql
1SELECT p.*, sa.*, ra.*, ci.*
2FROM Parcels p
3INNER JOIN Addresses sa ON p.ShipperAddressId = sa.Id
4INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id
5LEFT JOIN ParcelContentItems ci ON p.Id = ci.ParcelId
6WHERE p.Id = @id

And for the public endpoint:

sql
1SELECT p.*, ra.*
2FROM Parcels p
3INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id
4WHERE p.TrackingNumber = @trackingNumber

Fewer JOINs translate to a simpler, faster query. The public endpoint skips both the shipper address and content items JOINs.

Database Index for Tracking Number Lookups

The public endpoint queries by TrackingNumber, which is not the primary key. Without an index, every lookup performs a full table scan. Add a unique index in your EF Core configuration:

csharp
1modelBuilder.Entity<Parcel>(entity =>
2{
3 entity.HasIndex(p => p.TrackingNumber)
4 .IsUnique();
5});

This creates a B-tree index on the TrackingNumber column. Since tracking numbers are unique by design, the unique constraint also prevents accidental duplicates.

Comparing the Two Endpoints

AspectGET /api/parcels/{id}GET /api/tracking/{trackingNumber}
AudienceInternal operationsPublic consumers
Auth requiredYesNo
Lookup keyDatabase GUIDTracking number string
Response DTOParcelDetailResponseTrackingResponse
IncludesBoth addresses + content itemsRecipient address only
Content itemsYes (HS codes, values, origins)No (commercially sensitive)
Exposes IDsYesNo
Contact detailsFull shipper and recipientNone

This separation ensures internal tooling gets comprehensive data while public consumers get a safe, focused response.

Organizing the Endpoint Registration

As the number of endpoints grows, consider grouping them with MapGroup:

csharp
1var parcels = app.MapGroup("/api/parcels");
2parcels.MapGet("/{id:guid}", GetParcelById);
3parcels.MapPost("/", RegisterParcel);
4
5var tracking = app.MapGroup("/api/tracking");
6tracking.MapGet("/{trackingNumber}", GetByTrackingNumber);

Each group can have its own middleware, filters, and authorization policies applied once rather than repeated on each endpoint.

Summary

In this presentation, you learned how to:

  • Define separate response DTOs for internal and public audiences, including content items only in internal responses
  • Implement GET endpoints that query by different keys (GUID vs. tracking number)
  • Use eager loading with Include() tailored to each DTO's data requirements
  • Compute calculated fields (DaysInTransit, IsDelivered) during entity-to-DTO mapping
  • Add a database index for efficient tracking number lookups
  • Organize growing endpoint collections with MapGroup

Next, we will implement RFC 7807 Problem Details for consistent error handling across both endpoints.