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:
csharp1public class Parcel2{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; }89 public Guid ShipperAddressId { get; set; }10 public Address ShipperAddress { get; set; } = null!;1112 public Guid RecipientAddressId { get; set; }13 public Address RecipientAddress { get; set; } = null!;1415 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; }2122 public decimal DeclaredValue { get; set; }23 public string Currency { get; set; } = "USD";2425 public DateTimeOffset? EstimatedDeliveryDate { get; set; }26 public DateTimeOffset? ActualDeliveryDate { get; set; }2728 public int DeliveryAttempts { get; set; }2930 public DateTimeOffset CreatedAt { get; set; }31 public DateTimeOffset UpdatedAt { get; set; }3233 public ICollection<TrackingEvent> TrackingEvents { get; set; } = new List<TrackingEvent>();34 public DeliveryConfirmation? DeliveryConfirmation { get; set; }35}3637public enum ParcelStatus38{39 LabelCreated,40 PickedUp,41 InTransit,42 OutForDelivery,43 Delivered,44 Exception,45 Returned46}
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:
csharp1public class ParcelDetailResponse2{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;910 public AddressResponse ShipperAddress { get; set; } = null!;11 public AddressResponse RecipientAddress { get; set; } = null!;12 public List<ContentItemResponse> ContentItems { get; set; } = new();1314 public DateTime CreatedAt { get; set; }15 public DateTime? DeliveredAt { get; set; }16 public int DaysInTransit { get; set; }17 public bool IsDelivered { get; set; }18}1920public class ContentItemResponse21{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}3132public class AddressResponse33{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:
csharp1public class TrackingResponse2{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:
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 .Include(p => p.ContentItems)7 .FirstOrDefaultAsync(p => p.Id == id);89 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 }1617 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 IDsFirstOrDefaultAsyncoverFindAsync--FindAsyncdoes not supportInclude(), so we useFirstOrDefaultAsyncto 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:
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 = MapToAddressResponse(parcel.ShipperAddress),12 RecipientAddress = MapToAddressResponse(parcel.RecipientAddress),13 ContentItems = parcel.ContentItems.Select(ci => new ContentItemResponse14 {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.CountryOfOrigin23 }).ToList(),24 CreatedAt = parcel.CreatedAt,25 DeliveredAt = parcel.DeliveredAt,26 DaysInTransit = CalculateDaysInTransit(parcel),27 IsDelivered = parcel.Status == ParcelStatus.Delivered28 };29}3031static int CalculateDaysInTransit(Parcel parcel)32{33 var endDate = parcel.DeliveredAt ?? DateTime.UtcNow;34 return (int)(endDate - parcel.CreatedAt).TotalDays;35}3637static AddressResponse MapToAddressResponse(Address address)38{39 return new AddressResponse40 {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.Phone49 };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:
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 }1617 var response = MapToTrackingResponse(parcel);18 return Results.Ok(response);19});
Differences from the internal endpoint:
- Route uses
trackingNumberstring -- 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 thatInclude() - Different mapping method -- produces the simplified
TrackingResponse
The tracking response mapping:
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}
Eager Loading Strategy
Choosing what to include depends entirely on what the DTO needs:
csharp1// Internal endpoint - needs both addresses and content items2context.Parcels3 .Include(p => p.ShipperAddress)4 .Include(p => p.RecipientAddress)5 .Include(p => p.ContentItems)6 .FirstOrDefaultAsync(p => p.Id == id);78// Public endpoint - only needs recipient address9context.Parcels10 .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:
sql1SELECT p.*, sa.*, ra.*, ci.*2FROM Parcels p3INNER JOIN Addresses sa ON p.ShipperAddressId = sa.Id4INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id5LEFT JOIN ParcelContentItems ci ON p.Id = ci.ParcelId6WHERE p.Id = @id
And for the public endpoint:
sql1SELECT p.*, ra.*2FROM Parcels p3INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id4WHERE 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:
csharp1modelBuilder.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
| Aspect | GET /api/parcels/{id} | GET /api/tracking/{trackingNumber} |
|---|---|---|
| Audience | Internal operations | Public consumers |
| Auth required | Yes | No |
| Lookup key | Database GUID | Tracking number string |
| Response DTO | ParcelDetailResponse | TrackingResponse |
| Includes | Both addresses + content items | Recipient address only |
| Content items | Yes (HS codes, values, origins) | No (commercially sensitive) |
| Exposes IDs | Yes | No |
| Contact details | Full shipper and recipient | None |
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:
csharp1var parcels = app.MapGroup("/api/parcels");2parcels.MapGet("/{id:guid}", GetParcelById);3parcels.MapPost("/", RegisterParcel);45var 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.