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
1[HttpGet("{id:guid}")]
2public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id)
3{
4 var parcel = await _db.Parcels
5 .Include(p => p.ShipperAddress)
6 .Include(p => p.RecipientAddress)
7 .Include(p => p.ContentItems)
8 .FirstOrDefaultAsync(p => p.Id == id);
9
10 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 }
17
18 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 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.

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:

csharp
1public interface IParcelService
2{
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:

csharp
1public class ParcelDto
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; }
9
10 public AddressDto ShipperAddress { get; set; } = null!;
11 public AddressDto RecipientAddress { get; set; } = null!;
12 public List<ContentItemDto> ContentItems { get; set; } = new();
13
14 public DateTimeOffset CreatedAt { get; set; }
15 public DateTimeOffset? DeliveredAt { get; set; }
16}
17
18public class ParcelTrackingDto
19{
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}
29
30public class TrackingEventDto
31{
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}
38
39public class ContentItemDto
40{
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}
50
51public class AddressDto
52{
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:

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 return parcel == null ? null : MapToParcelDto(parcel);
19 }
20
21 public async Task<ParcelTrackingDto?> GetByTrackingNumberAsync(string trackingNumber)
22 {
23 var parcel = await _context.Parcels
24 .Include(p => p.RecipientAddress)
25 .Include(p => p.TrackingEvents)
26 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
27
28 return parcel == null ? null : MapToTrackingDto(parcel);
29 }
30
31 private static ParcelDto MapToParcelDto(Parcel parcel)
32 {
33 return new ParcelDto
34 {
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.ActualDeliveryDate
46 };
47 }
48
49 private static ParcelTrackingDto MapToTrackingDto(Parcel parcel)
50 {
51 return new ParcelTrackingDto
52 {
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.TrackingEvents
61 .OrderBy(e => e.Timestamp)
62 .Select(MapToTrackingEventDto)
63 .ToList()
64 };
65 }
66
67 private static AddressDto MapToAddressDto(Address address)
68 {
69 return new AddressDto
70 {
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.Phone
80 };
81 }
82
83 private static ContentItemDto MapToContentItemDto(ContentItem item)
84 {
85 return new ContentItemDto
86 {
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.CountryOfOrigin
95 };
96 }
97
98 private static TrackingEventDto MapToTrackingEventDto(TrackingEvent evt)
99 {
100 return new TrackingEventDto
101 {
102 Id = evt.Id,
103 Status = evt.Status.ToString(),
104 Location = evt.Location,
105 Notes = evt.Notes,
106 Timestamp = evt.Timestamp
107 };
108 }
109}

Key implementation details:

  • Eager loading with Include() -- each method loads only the relationships it needs
  • Separate mapping methods -- MapToParcelDto for internal details, MapToTrackingDto for public tracking
  • Ordered tracking events -- events are sorted by timestamp so consumers see them chronologically
  • Null returns -- methods return null when 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:

csharp
1[HttpGet("{id:guid}")]
2public async Task<ActionResult<ParcelDetailResponse>> 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 var response = MapToDetailResponse(parcel);
15 return Ok(response);
16}
17
18[HttpGet("/api/tracking/{trackingNumber}")]
19public async Task<ActionResult<TrackingResponse>> GetByTrackingNumber(string trackingNumber)
20{
21 var tracking = await _parcelService.GetByTrackingNumberAsync(trackingNumber);
22
23 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 }
30
31 var response = MapToTrackingResponse(tracking);
32 return Ok(response);
33}

The controller now:

  1. Calls the service method
  2. Checks for null
  3. Maps the service DTO to the API response DTO
  4. Returns the result

Mapping Service DTOs to Response DTOs

The API layer adds calculated fields that are not part of the service contract:

csharp
1static ParcelDetailResponse MapToDetailResponse(ParcelDto dto)
2{
3 return new ParcelDetailResponse
4 {
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}
20
21static TrackingResponse MapToTrackingResponse(ParcelTrackingDto dto)
22{
23 return new TrackingResponse
24 {
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}
36
37static int CalculateDaysInTransit(DateTimeOffset createdAt, DateTimeOffset? deliveredAt)
38{
39 var endDate = deliveredAt ?? DateTimeOffset.UtcNow;
40 return (int)(endDate - createdAt).TotalDays;
41}
42
43static AddressResponse MapToAddressResponse(AddressDto dto)
44{
45 return new AddressResponse
46 {
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.Phone
55 };
56}
57
58static ContentItemResponse MapToContentItemResponse(ContentItemDto dto)
59{
60 return new ContentItemResponse
61 {
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.CountryOfOrigin
70 };
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:

  1. Consistent eager loading -- the service ensures related entities are always loaded correctly
  2. Reusable queries -- multiple endpoints or background jobs can use the same retrieval logic
  3. Testability -- service methods are easy to unit test without spinning up HTTP infrastructure
  4. 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:

csharp
1// GetByIdAsync - needs both addresses and content items
2var parcel = await _context.Parcels
3 .Include(p => p.ShipperAddress)
4 .Include(p => p.RecipientAddress)
5 .Include(p => p.ContentItems)
6 .FirstOrDefaultAsync(p => p.Id == id);
7
8// GetByTrackingNumberAsync - needs recipient address and tracking events
9var parcel = await _context.Parcels
10 .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:

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 ContentItems ci ON p.Id = ci.ParcelId
6WHERE p.Id = @id

And for GetByTrackingNumberAsync:

sql
1SELECT p.*, ra.*, te.*
2FROM Parcels p
3INNER JOIN Addresses ra ON p.RecipientAddressId = ra.Id
4LEFT JOIN TrackingEvents te ON p.Id = te.ParcelId
5WHERE 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:

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
Service methodGetByIdAsyncGetByTrackingNumberAsync
Service DTOParcelDtoParcelTrackingDto
Response DTOParcelDetailResponseTrackingResponse
IncludesBoth addresses + content itemsRecipient address + tracking events
Content itemsYes (HS codes, values, origins)No (commercially sensitive)
Tracking eventsNo (internal ops use other tools)Yes (customer journey visibility)
Exposes IDsYesNo
Contact detailsFull shipper and recipientNone

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:

csharp
1[ApiController]
2[Route("api/parcels")]
3public class ParcelsController : ControllerBase
4{
5 // GET /api/parcels/{id}
6 [HttpGet("{id:guid}")]
7 public async Task<ActionResult<ParcelDetailResponse>> GetById(Guid id) { ... }
8
9 // POST /api/parcels
10 [HttpPost]
11 public async Task<ActionResult<ParcelDto>> RegisterParcel(...) { ... }
12}
13
14[ApiController]
15[Route("api/tracking")]
16public class TrackingController : ControllerBase
17{
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 IParcelService with 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.