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:
csharp1[HttpGet("{id:guid}")]2public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)3{4 var parcel = await _db.Parcels5 .Include(p => p.ShipperAddress)6 .Include(p => p.RecipientAddress)7 .Include(p => p.ContentItems)8 .FirstOrDefaultAsync(p => p.Id == id);910 if (parcel is null)11 {12 return Problem(13 detail: $"No parcel exists with ID '{id}'.",14 title: "Parcel Not Found",15 statusCode: StatusCodes.Status404NotFound);16 }1718 var response = MapToDetailResponse(parcel);19 return Ok(response);20}
Key decisions in this implementation:
{id:guid}route constraint -- ASP.NET Core validates the parameter is a valid GUID before the action 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.
Refactoring to Service Layer
As we add retrieval operations, the controller endpoints are starting to contain database queries and business logic. Following the service layer pattern we established in Topic 3, we should move this logic to IParcelService.
Expanding IParcelService
Add retrieval methods to the service interface:
csharp1public interface IParcelService2{3 Task<ParcelDto> RegisterAsync(RegisterParcelRequest request);4 Task<ParcelDto?> GetByIdAsync(Guid id);5 Task<ParcelTrackingDto?> GetByTrackingNumberAsync(string trackingNumber);6}
Defining Service DTOs
The service layer uses its own DTOs to decouple the domain from the API layer:
csharp1public class ParcelDto2{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; }910 public AddressDto ShipperAddress { get; set; } = null!;11 public AddressDto RecipientAddress { get; set; } = null!;12 public List<ContentItemDto> ContentItems { get; set; } = new();1314 public DateTimeOffset CreatedAt { get; set; }15 public DateTimeOffset? DeliveredAt { get; set; }16}1718public class ParcelTrackingDto19{20 public string TrackingNumber { get; set; } = string.Empty;21 public string Status { get; set; } = string.Empty;22 public string RecipientCity { get; set; } = string.Empty;23 public string RecipientState { get; set; } = string.Empty;24 public decimal Weight { get; set; }25 public DateTimeOffset CreatedAt { get; set; }26 public DateTimeOffset? DeliveredAt { get; set; }27 public List<TrackingEventDto> TrackingEvents { get; set; } = new();28}2930public class TrackingEventDto31{32 public Guid Id { get; set; }33 public string Status { get; set; } = string.Empty;34 public string? Location { get; set; }35 public string? Notes { get; set; }36 public DateTimeOffset Timestamp { get; set; }37}3839public class ContentItemDto40{41 public string HsCode { get; set; } = string.Empty;42 public string Description { get; set; } = string.Empty;43 public int Quantity { get; set; }44 public decimal UnitValue { get; set; }45 public string Currency { get; set; } = string.Empty;46 public decimal Weight { get; set; }47 public string WeightUnit { get; set; } = string.Empty;48 public string CountryOfOrigin { get; set; } = string.Empty;49}5051public class AddressDto52{53 public Guid Id { get; set; }54 public string Street1 { get; set; } = string.Empty;55 public string? Street2 { get; set; }56 public string City { get; set; } = string.Empty;57 public string State { get; set; } = string.Empty;58 public string PostalCode { get; set; } = string.Empty;59 public string CountryCode { get; set; } = string.Empty;60 public string? ContactName { get; set; }61 public string? Phone { get; set; }62}
Notice that ParcelTrackingDto includes a TrackingEvents collection. Public consumers need to see the parcel's journey, so we include the full event history in the tracking response.
Implementing Service Methods
Update ParcelService to implement the retrieval operations:
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 return parcel == null ? null : MapToParcelDto(parcel);19 }2021 public async Task<ParcelTrackingDto?> GetByTrackingNumberAsync(string trackingNumber)22 {23 var parcel = await _context.Parcels24 .Include(p => p.RecipientAddress)25 .Include(p => p.TrackingEvents)26 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);2728 return parcel == null ? null : MapToTrackingDto(parcel);29 }3031 private static ParcelDto MapToParcelDto(Parcel parcel)32 {33 return new ParcelDto34 {35 Id = parcel.Id,36 TrackingNumber = parcel.TrackingNumber,37 Status = parcel.Status.ToString(),38 Weight = parcel.Weight,39 WeightUnit = parcel.WeightUnit.ToString(),40 Description = parcel.Description,41 ShipperAddress = MapToAddressDto(parcel.ShipperAddress),42 RecipientAddress = MapToAddressDto(parcel.RecipientAddress),43 ContentItems = parcel.ContentItems.Select(MapToContentItemDto).ToList(),44 CreatedAt = parcel.CreatedAt,45 DeliveredAt = parcel.ActualDeliveryDate46 };47 }4849 private static ParcelTrackingDto MapToTrackingDto(Parcel parcel)50 {51 return new ParcelTrackingDto52 {53 TrackingNumber = parcel.TrackingNumber,54 Status = parcel.Status.ToString(),55 RecipientCity = parcel.RecipientAddress.City,56 RecipientState = parcel.RecipientAddress.State,57 Weight = parcel.Weight,58 CreatedAt = parcel.CreatedAt,59 DeliveredAt = parcel.ActualDeliveryDate,60 TrackingEvents = parcel.TrackingEvents61 .OrderBy(e => e.Timestamp)62 .Select(MapToTrackingEventDto)63 .ToList()64 };65 }6667 private static AddressDto MapToAddressDto(Address address)68 {69 return new AddressDto70 {71 Id = address.Id,72 Street1 = address.Street1,73 Street2 = address.Street2,74 City = address.City,75 State = address.State,76 PostalCode = address.PostalCode,77 CountryCode = address.CountryCode,78 ContactName = address.ContactName,79 Phone = address.Phone80 };81 }8283 private static ContentItemDto MapToContentItemDto(ContentItem item)84 {85 return new ContentItemDto86 {87 HsCode = item.HsCode,88 Description = item.Description,89 Quantity = item.Quantity,90 UnitValue = item.UnitValue,91 Currency = item.Currency,92 Weight = item.Weight,93 WeightUnit = item.WeightUnit.ToString(),94 CountryOfOrigin = item.CountryOfOrigin95 };96 }9798 private static TrackingEventDto MapToTrackingEventDto(TrackingEvent evt)99 {100 return new TrackingEventDto101 {102 Id = evt.Id,103 Status = evt.Status.ToString(),104 Location = evt.Location,105 Notes = evt.Notes,106 Timestamp = evt.Timestamp107 };108 }109}
Key implementation details:
- Eager loading with
Include()-- each method loads only the relationships it needs - Separate mapping methods --
MapToParcelDtofor internal details,MapToTrackingDtofor public tracking - Ordered tracking events -- events are sorted by timestamp so consumers see them chronologically
- Null returns -- methods return
nullwhen the parcel is not found, allowing the controller to decide the response
Refactored Controller Actions
With the service in place, the controller becomes a thin HTTP adapter:
csharp1[HttpGet("{id:guid}")]2public async Task<ActionResult<ParcelDetailResponse>> 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 var response = MapToDetailResponse(parcel);15 return Ok(response);16}1718[HttpGet("/api/tracking/{trackingNumber}")]19public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)20{21 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);2223 if (tracking is null)24 {25 return Problem(26 detail: $"No parcel found with tracking number '{trackingNumber}'.",27 title: "Tracking Number Not Found",28 statusCode: StatusCodes.Status404NotFound);29 }3031 var response = MapToTrackingResponse(tracking);32 return Ok(response);33}
The controller now:
- Calls the service method
- Checks for null
- Maps the service DTO to the API response DTO
- Returns the result
Mapping Service DTOs to Response DTOs
The API layer adds calculated fields that are not part of the service contract:
csharp1static ParcelDetailResponse MapToDetailResponse(ParcelDto dto)2{3 return new ParcelDetailResponse4 {5 Id = dto.Id,6 TrackingNumber = dto.TrackingNumber,7 Status = dto.Status,8 Weight = dto.Weight,9 WeightUnit = dto.WeightUnit,10 Description = dto.Description ?? string.Empty,11 ShipperAddress = MapToAddressResponse(dto.ShipperAddress),12 RecipientAddress = MapToAddressResponse(dto.RecipientAddress),13 ContentItems = dto.ContentItems.Select(MapToContentItemResponse).ToList(),14 CreatedAt = dto.CreatedAt.DateTime,15 DeliveredAt = dto.DeliveredAt?.DateTime,16 DaysInTransit = CalculateDaysInTransit(dto.CreatedAt, dto.DeliveredAt),17 IsDelivered = dto.Status == "Delivered"18 };19}2021static TrackingResponse MapToTrackingResponse(ParcelTrackingDto dto)22{23 return new TrackingResponse24 {25 TrackingNumber = dto.TrackingNumber,26 Status = dto.Status,27 RecipientCity = dto.RecipientCity,28 RecipientState = dto.RecipientState,29 Weight = dto.Weight,30 ShippedAt = dto.CreatedAt.DateTime,31 DeliveredAt = dto.DeliveredAt?.DateTime,32 DaysInTransit = CalculateDaysInTransit(dto.CreatedAt, dto.DeliveredAt),33 IsDelivered = dto.Status == "Delivered"34 };35}3637static int CalculateDaysInTransit(DateTimeOffset createdAt, DateTimeOffset? deliveredAt)38{39 var endDate = deliveredAt ?? DateTimeOffset.UtcNow;40 return (int)(endDate - createdAt).TotalDays;41}4243static AddressResponse MapToAddressResponse(AddressDto dto)44{45 return new AddressResponse46 {47 Id = dto.Id,48 Street1 = dto.Street1,49 City = dto.City,50 State = dto.State,51 PostalCode = dto.PostalCode,52 CountryCode = dto.CountryCode,53 ContactName = dto.ContactName,54 Phone = dto.Phone55 };56}5758static ContentItemResponse MapToContentItemResponse(ContentItemDto dto)59{60 return new ContentItemResponse61 {62 HsCode = dto.HsCode,63 Description = dto.Description,64 Quantity = dto.Quantity,65 UnitValue = dto.UnitValue,66 Currency = dto.Currency,67 Weight = dto.Weight,68 WeightUnit = dto.WeightUnit,69 CountryOfOrigin = dto.CountryOfOrigin70 };71}
Calculated fields like DaysInTransit and IsDelivered remain in the API layer because they are presentation concerns. The service layer returns raw timestamps and status values, and the controller decides how to present them.
Why Service Layer for Retrieval?
You might wonder why we need a service layer for simple data retrieval. The benefits become clear as the system grows:
- Consistent eager loading -- the service ensures related entities are always loaded correctly
- Reusable queries -- multiple endpoints or background jobs can use the same retrieval logic
- Testability -- service methods are easy to unit test without spinning up HTTP infrastructure
- Business logic centralization -- when you add parcel retrieval rules (access control, filtering), they live in one place
The service layer is not about indirection for its own sake. It is about keeping query logic, entity mapping, and business rules out of HTTP handlers.
Eager Loading Strategy in the Service
Choosing what to include depends entirely on what the DTO needs. The service methods use different Include() calls:
csharp1// GetByIdAsync - needs both addresses and content items2var parcel = await _context.Parcels3 .Include(p => p.ShipperAddress)4 .Include(p => p.RecipientAddress)5 .Include(p => p.ContentItems)6 .FirstOrDefaultAsync(p => p.Id == id);78// GetByTrackingNumberAsync - needs recipient address and tracking events9var parcel = await _context.Parcels10 .Include(p => p.RecipientAddress)11 .Include(p => p.TrackingEvents)12 .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 GetByIdAsync:
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 ContentItems ci ON p.Id = ci.ParcelId6WHERE p.Id = @id
And for GetByTrackingNumberAsync:
sql1SELECT p.*, ra.*, te.*2FROM Parcels p3INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id4LEFT JOIN TrackingEvents te ON p.Id = te.ParcelId5WHERE p.TrackingNumber = @trackingNumber
Fewer JOINs translate to a simpler, faster query. The tracking endpoint skips the shipper address and content items, but includes tracking events so customers can see the parcel's journey.
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 |
| Service method | GetByIdAsync | GetByTrackingNumberAsync |
| Service DTO | ParcelDto | ParcelTrackingDto |
| Response DTO | ParcelDetailResponse | TrackingResponse |
| Includes | Both addresses + content items | Recipient address + tracking events |
| Content items | Yes (HS codes, values, origins) | No (commercially sensitive) |
| Tracking events | No (internal ops use other tools) | Yes (customer journey visibility) |
| 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 with event history.
Organizing Controller Routes
As the number of endpoints grows, organize them using separate controllers:
csharp1[ApiController]2[Route("api/parcels")]3public class ParcelsController : ControllerBase4{5 // GET /api/parcels/{id}6 [HttpGet("{id:guid}")]7 public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id) { ... }89 // POST /api/parcels10 [HttpPost]11 public async Task<ActionResult<ParcelDto>> RegisterParcel(...) { ... }12}1314[ApiController]15[Route("api/tracking")]16public class TrackingController : ControllerBase17{18 // GET /api/tracking/{trackingNumber}19 [HttpGet("{trackingNumber}")]20 public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber) { ... }21}
Each controller can have its own authorization policies, filters, and middleware applied at the controller level using attributes.
Summary
In this presentation, you learned how to:
- Expand
IParcelServicewith retrieval methods (GetByIdAsync,GetByTrackingNumberAsync) - Define service DTOs (
ParcelDto,ParcelTrackingDto) that include tracking events for public consumers - Implement service methods with appropriate eager loading for each use case
- Refactor controller endpoints to use the service layer instead of direct database access
- Map service DTOs to API response DTOs, adding calculated fields in the API layer
- Use eager loading strategically -- include tracking events for public tracking, content items for internal details
- Add a database index for efficient tracking number lookups
- Separate concerns between the service layer (data access, domain logic) and API layer (HTTP, presentation)
Next, we will implement RFC 7807 Problem Details and custom exceptions for consistent error handling across both endpoints.