20 minlesson

Building the Registration Endpoint

Building the Registration Endpoint

Now that you understand the registration workflow, let's implement it. We'll build the POST endpoint, define the request and response DTOs, generate tracking numbers, and calculate estimated delivery dates.

Request and Response DTOs

Start with the data structures. The request DTO captures everything the client sends. The response DTO includes the generated fields.

Value Objects for Measurements

Weight, dimensions, and declared value each combine a number with a unit. Define them as nested DTOs:

csharp
1public class WeightDto
2{
3 public decimal Value { get; set; }
4 public string Unit { get; set; } = string.Empty; // "kg", "lb"
5}
6
7public class DimensionsDto
8{
9 public decimal Length { get; set; }
10 public decimal Width { get; set; }
11 public decimal Height { get; set; }
12 public string Unit { get; set; } = string.Empty; // "cm", "in"
13}
14
15public class DeclaredValueDto
16{
17 public decimal Amount { get; set; }
18 public string Currency { get; set; } = string.Empty; // "USD", "EUR"
19}

These value objects make the API self-describing. A client knows that weight includes both the numeric value and the unit, so there's no ambiguity.

Content Item DTO

Each content item describes one type of goods inside the parcel:

csharp
1public class ContentItemDto
2{
3 public string HsCode { get; set; } = string.Empty; // Format: XXXX.XX
4 public string Description { get; set; } = string.Empty;
5 public int Quantity { get; set; }
6 public decimal UnitValue { get; set; }
7 public string Currency { get; set; } = "USD"; // ISO 4217
8 public decimal Weight { get; set; }
9 public string WeightUnit { get; set; } = string.Empty; // "kg", "lb"
10 public string CountryOfOrigin { get; set; } = string.Empty; // ISO 3166-1 alpha-2
11}

The HsCode follows the Harmonized System format XXXX.XX (e.g., 8471.30 for portable computers). Carrier APIs like FedEx and UPS require these codes for international customs declarations.

The Registration Request

csharp
1public class RegisterParcelRequest
2{
3 public Guid ShipperAddressId { get; set; }
4 public Guid RecipientAddressId { get; set; }
5 public string ServiceType { get; set; } = string.Empty;
6 public string Description { get; set; } = string.Empty;
7 public WeightDto Weight { get; set; } = null!;
8 public DimensionsDto Dimensions { get; set; } = null!;
9 public DeclaredValueDto DeclaredValue { get; set; } = null!;
10 public List<ContentItemDto> ContentItems { get; set; } = new();
11}

Notice what's not in the request: tracking number, status, estimated delivery date, and timestamps. These are all server-generated. The ContentItems list must contain at least one item — every parcel must declare its contents.

The Parcel Response

csharp
1public class ParcelResponse
2{
3 public Guid Id { get; set; }
4 public string TrackingNumber { get; set; } = string.Empty;
5 public Guid ShipperAddressId { get; set; }
6 public Guid RecipientAddressId { get; set; }
7 public string ServiceType { get; set; } = string.Empty;
8 public string Status { get; set; } = string.Empty;
9 public string Description { get; set; } = string.Empty;
10 public WeightDto Weight { get; set; } = null!;
11 public DimensionsDto Dimensions { get; set; } = null!;
12 public DeclaredValueDto DeclaredValue { get; set; } = null!;
13 public List<ContentItemDto> ContentItems { get; set; } = new();
14 public DateTime EstimatedDeliveryDate { get; set; }
15 public DateTime CreatedAt { get; set; }
16}

The response is a superset of the request -- it includes everything the client sent plus the fields the server generated.

The Domain Entity

The DTO maps to a domain entity stored in the database:

csharp
1public class Parcel
2{
3 public Guid Id { get; set; }
4 public string TrackingNumber { get; set; } = string.Empty;
5 public Guid ShipperAddressId { get; set; }
6 public Guid RecipientAddressId { get; set; }
7 public string ServiceType { get; set; } = string.Empty;
8 public string Status { get; set; } = string.Empty;
9 public string Description { get; set; } = string.Empty;
10
11 // Weight
12 public decimal WeightValue { get; set; }
13 public string WeightUnit { get; set; } = string.Empty;
14
15 // Dimensions
16 public decimal DimensionLength { get; set; }
17 public decimal DimensionWidth { get; set; }
18 public decimal DimensionHeight { get; set; }
19 public string DimensionUnit { get; set; } = string.Empty;
20
21 // Declared Value
22 public decimal DeclaredValueAmount { get; set; }
23 public string DeclaredValueCurrency { get; set; } = string.Empty;
24
25 public DateTime EstimatedDeliveryDate { get; set; }
26 public DateTime CreatedAt { get; set; }
27 public DateTime UpdatedAt { get; set; }
28
29 // Navigation properties
30 public Address ShipperAddress { get; set; } = null!;
31 public Address RecipientAddress { get; set; } = null!;
32 public List<TrackingEvent> TrackingEvents { get; set; } = new();
33 public List<ParcelContentItem> ContentItems { get; set; } = new();
34}

The value objects (weight, dimensions, declared value) are flattened into individual columns. EF Core owned types could be used, but flat columns are simpler to query and index.

Tracking Number Generation

The tracking number generator creates a PKT prefix followed by 12 random alphanumeric characters:

csharp
1public static class TrackingNumberGenerator
2{
3 private const string Prefix = "PKT";
4 private const string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
5 private const int RandomLength = 12;
6
7 public static string Generate()
8 {
9 return Prefix + RandomString(RandomLength);
10 }
11
12 private static string RandomString(int length)
13 {
14 var chars = new char[length];
15 for (var i = 0; i < length; i++)
16 {
17 chars[i] = Characters[Random.Shared.Next(Characters.Length)];
18 }
19 return new string(chars);
20 }
21}

Random.Shared is a thread-safe singleton introduced in .NET 6. It's appropriate here because tracking numbers don't need cryptographic randomness -- they just need to be unique and non-sequential.

Why Not Use Guid.NewGuid().ToString()?

GUIDs are unique but they're 32 hex characters long and not human-friendly. Tracking numbers need to be typed into search fields and read over the phone. PKT7G4KM2XB9NQ1 is far more practical than 550e8400-e29b-41d4-a716-446655440000.

Estimated Delivery Calculation

Different service types have different transit windows. The calculation takes the registration date and adds the appropriate number of business days:

csharp
1public static class DeliveryEstimator
2{
3 private static readonly Dictionary<string, int> TransitDays = new()
4 {
5 ["Standard"] = 7,
6 ["Express"] = 3,
7 ["Overnight"] = 1,
8 ["Economy"] = 10
9 };
10
11 public static DateTime EstimateDeliveryDate(
12 string serviceType,
13 DateTime registrationDate)
14 {
15 var days = TransitDays.GetValueOrDefault(serviceType, 7);
16 return AddBusinessDays(registrationDate, days);
17 }
18
19 private static DateTime AddBusinessDays(DateTime start, int days)
20 {
21 var current = start;
22 var added = 0;
23
24 while (added < days)
25 {
26 current = current.AddDays(1);
27 if (current.DayOfWeek != DayOfWeek.Saturday &&
28 current.DayOfWeek != DayOfWeek.Sunday)
29 {
30 added++;
31 }
32 }
33
34 return current;
35 }
36}

The AddBusinessDays method skips Saturdays and Sundays. For Overnight service registered on a Friday, the estimated delivery would be Monday.

Creating the Parcel Service

Following the service layer pattern from Topic 2, controllers should inject services from the Application layer, not the DbContext directly. This separation keeps business logic in services and HTTP concerns in controllers.

The Service Interface

Create IParcelService.cs in src/ParcelTracking.Application/Services/:

csharp
1using ParcelTracking.Application.DTOs.Parcels;
2
3namespace ParcelTracking.Application.Services;
4
5public interface IParcelService
6{
7 Task<ParcelResponse> RegisterAsync(RegisterParcelRequest request);
8}

This interface defines the contract for parcel operations. As you build more features, you'll add methods like GetByTrackingNumberAsync and UpdateStatusAsync.

The Service Implementation

Create ParcelService.cs in src/ParcelTracking.Application/Services/:

csharp
1using Microsoft.EntityFrameworkCore;
2using Microsoft.Extensions.Logging;
3using ParcelTracking.Application.DTOs.Parcels;
4using ParcelTracking.Domain.Entities;
5using ParcelTracking.Infrastructure.Data;
6
7namespace ParcelTracking.Application.Services;
8
9public class ParcelService : IParcelService
10{
11 private readonly ParcelTrackingDbContext _db;
12 private readonly ILogger<ParcelService> _logger;
13
14 public ParcelService(
15 ParcelTrackingDbContext db,
16 ILogger<ParcelService> logger)
17 {
18 _db = db;
19 _logger = logger;
20 }
21
22 public async Task<ParcelResponse> RegisterAsync(RegisterParcelRequest request)
23 {
24 // Step 1: Validate that referenced addresses exist
25 var shipperAddress = await _db.Addresses
26 .FindAsync(request.ShipperAddressId);
27
28 if (shipperAddress is null)
29 throw new InvalidOperationException(
30 $"Shipper address {request.ShipperAddressId} not found.");
31
32 var recipientAddress = await _db.Addresses
33 .FindAsync(request.RecipientAddressId);
34
35 if (recipientAddress is null)
36 throw new InvalidOperationException(
37 $"Recipient address {request.RecipientAddressId} not found.");
38
39 // Step 2: Generate tracking number
40 var trackingNumber = TrackingNumberGenerator.Generate();
41
42 // Step 3: Calculate estimated delivery
43 var now = DateTime.UtcNow;
44 var estimatedDelivery = DeliveryEstimator
45 .EstimateDeliveryDate(request.ServiceType, now);
46
47 // Step 4: Create the parcel entity
48 var parcel = new Parcel
49 {
50 Id = Guid.NewGuid(),
51 TrackingNumber = trackingNumber,
52 ShipperAddressId = request.ShipperAddressId,
53 RecipientAddressId = request.RecipientAddressId,
54 ServiceType = request.ServiceType,
55 Status = "LabelCreated",
56 Description = request.Description,
57 WeightValue = request.Weight.Value,
58 WeightUnit = request.Weight.Unit,
59 DimensionLength = request.Dimensions.Length,
60 DimensionWidth = request.Dimensions.Width,
61 DimensionHeight = request.Dimensions.Height,
62 DimensionUnit = request.Dimensions.Unit,
63 DeclaredValueAmount = request.DeclaredValue.Amount,
64 DeclaredValueCurrency = request.DeclaredValue.Currency,
65 EstimatedDeliveryDate = estimatedDelivery,
66 CreatedAt = now,
67 UpdatedAt = now,
68 ContentItems = request.ContentItems.Select(ci => new ParcelContentItem
69 {
70 Id = Guid.NewGuid(),
71 HsCode = ci.HsCode,
72 Description = ci.Description,
73 Quantity = ci.Quantity,
74 UnitValue = ci.UnitValue,
75 Currency = ci.Currency,
76 Weight = ci.Weight,
77 WeightUnit = ci.WeightUnit,
78 CountryOfOrigin = ci.CountryOfOrigin
79 }).ToList()
80 };
81
82 // Step 5: Create the initial tracking event
83 var trackingEvent = new TrackingEvent
84 {
85 Id = Guid.NewGuid(),
86 ParcelId = parcel.Id,
87 Status = "LabelCreated",
88 Description = "Label created, shipment information sent to carrier",
89 Timestamp = now,
90 Location = null
91 };
92
93 // Step 6: Persist both in a single transaction
94 _db.Parcels.Add(parcel);
95 _db.TrackingEvents.Add(trackingEvent);
96 await _db.SaveChangesAsync();
97
98 _logger.LogInformation(
99 "Registered parcel {TrackingNumber} with ID {ParcelId}",
100 parcel.TrackingNumber, parcel.Id);
101
102 // Step 7: Return the response
103 return MapToResponse(parcel);
104 }
105
106 private static ParcelResponse MapToResponse(Parcel parcel)
107 {
108 return new ParcelResponse
109 {
110 Id = parcel.Id,
111 TrackingNumber = parcel.TrackingNumber,
112 ShipperAddressId = parcel.ShipperAddressId,
113 RecipientAddressId = parcel.RecipientAddressId,
114 ServiceType = parcel.ServiceType,
115 Status = parcel.Status,
116 Description = parcel.Description,
117 Weight = new WeightDto
118 {
119 Value = parcel.WeightValue,
120 Unit = parcel.WeightUnit
121 },
122 Dimensions = new DimensionsDto
123 {
124 Length = parcel.DimensionLength,
125 Width = parcel.DimensionWidth,
126 Height = parcel.DimensionHeight,
127 Unit = parcel.DimensionUnit
128 },
129 DeclaredValue = new DeclaredValueDto
130 {
131 Amount = parcel.DeclaredValueAmount,
132 Currency = parcel.DeclaredValueCurrency
133 },
134 ContentItems = parcel.ContentItems.Select(ci => new ContentItemDto
135 {
136 HsCode = ci.HsCode,
137 Description = ci.Description,
138 Quantity = ci.Quantity,
139 UnitValue = ci.UnitValue,
140 Currency = ci.Currency,
141 Weight = ci.Weight,
142 WeightUnit = ci.WeightUnit,
143 CountryOfOrigin = ci.CountryOfOrigin
144 }).ToList(),
145 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate,
146 CreatedAt = parcel.CreatedAt
147 };
148 }
149}

Service Design Notes

Exception handling: The service throws InvalidOperationException when addresses don't exist. The controller will catch these and return appropriate HTTP status codes.

Single transaction: Both the parcel and tracking event are added before SaveChangesAsync. EF Core wraps everything in one database transaction automatically.

Logging: Structured logging provides an audit trail of parcel registrations.

Manual mapping: The MapToResponse method explicitly maps entity properties to DTOs, avoiding the complexity of AutoMapper.

The Controller Action

With the service layer in place, the controller becomes a thin HTTP adapter:

csharp
1using Microsoft.AspNetCore.Mvc;
2using ParcelTracking.Application.DTOs.Parcels;
3using ParcelTracking.Application.Services;
4
5namespace ParcelTracking.Api.Controllers;
6
7[ApiController]
8[Route("api/[controller]")]
9public class ParcelsController : ControllerBase
10{
11 private readonly IParcelService _parcelService;
12 private readonly ILogger<ParcelsController> _logger;
13
14 public ParcelsController(
15 IParcelService parcelService,
16 ILogger<ParcelsController> logger)
17 {
18 _parcelService = parcelService;
19 _logger = logger;
20 }
21
22 [HttpPost]
23 [ProducesResponseType(typeof(ParcelResponse), StatusCodes.Status201Created)]
24 [ProducesResponseType(StatusCodes.Status404NotFound)]
25 public async Task<IActionResult> Register(RegisterParcelRequest request)
26 {
27 try
28 {
29 var response = await _parcelService.RegisterAsync(request);
30
31 return CreatedAtAction(
32 nameof(GetByTrackingNumber),
33 new { trackingNumber = response.TrackingNumber },
34 response);
35 }
36 catch (InvalidOperationException ex)
37 {
38 return NotFound(new { message = ex.Message });
39 }
40 }
41}

The controller's responsibilities are minimal:

  1. Accept the HTTP request and bind it to RegisterParcelRequest
  2. Call the service
  3. Catch exceptions and translate them to HTTP status codes
  4. Return 201 Created with the Location header

Key Design Decisions

CreatedAtAction return: Returns HTTP 201 with a Location header pointing to the GET endpoint for the new parcel. This follows REST conventions.

Exception translation: Business logic exceptions become HTTP 404 responses. The controller acts as the boundary between domain and HTTP.

UTC timestamps: All dates use DateTime.UtcNow to avoid timezone issues.

Registering the Service

Add the service to the dependency injection container in Program.cs:

csharp
1// Register services
2builder.Services.AddScoped<IParcelService, ParcelService>();

Place this after the DbContext registration and before builder.Build(). The AddScoped lifetime means one instance per HTTP request.

Testing with an HTTP Request

Here's what the registration request looks like:

http
1POST /api/parcels HTTP/1.1
2Content-Type: application/json
3
4{
5 "shipperAddressId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
6 "recipientAddressId": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
7 "serviceType": "Express",
8 "description": "Electronics - Laptop",
9 "contentItems": [
10 {
11 "hsCode": "8471.30",
12 "description": "Portable laptop computer",
13 "quantity": 1,
14 "unitValue": 1200.00,
15 "currency": "USD",
16 "weight": 2.1,
17 "weightUnit": "kg",
18 "countryOfOrigin": "CN"
19 }
20 ],
21 "weight": {
22 "value": 2.5,
23 "unit": "kg"
24 },
25 "dimensions": {
26 "length": 40,
27 "width": 30,
28 "height": 10,
29 "unit": "cm"
30 },
31 "declaredValue": {
32 "amount": 1200.00,
33 "currency": "USD"
34 }
35}

And the expected response:

http
1HTTP/1.1 201 Created
2Location: /api/parcels/PKT7G4KM2XB9NQ1
3Content-Type: application/json
4
5{
6 "id": "...",
7 "trackingNumber": "PKT7G4KM2XB9NQ1",
8 "shipperAddressId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
9 "recipientAddressId": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
10 "serviceType": "Express",
11 "status": "LabelCreated",
12 "description": "Electronics - Laptop",
13 "contentItems": [
14 {
15 "hsCode": "8471.30",
16 "description": "Portable laptop computer",
17 "quantity": 1,
18 "unitValue": 1200.00,
19 "currency": "USD",
20 "weight": 2.1,
21 "weightUnit": "kg",
22 "countryOfOrigin": "CN"
23 }
24 ],
25 "weight": {
26 "value": 2.5,
27 "unit": "kg"
28 },
29 "dimensions": {
30 "length": 40,
31 "width": 30,
32 "height": 10,
33 "unit": "cm"
34 },
35 "declaredValue": {
36 "amount": 1200.00,
37 "currency": "USD"
38 },
39 "estimatedDeliveryDate": "2026-02-19T00:00:00Z",
40 "createdAt": "2026-02-15T14:30:00Z"
41}

The response includes the generated tracking number, the LabelCreated status, the content items with their HS codes, and the estimated delivery date calculated from the Express service type.

Mapping Between DTOs and Entities

The MapToResponse method converts the flat entity back into the nested DTO structure. In a larger codebase, you might use a library like AutoMapper or Mapster. For this API, manual mapping keeps dependencies minimal and makes the conversion explicit.

The mapping flow is:

1RegisterParcelRequest --> Parcel (entity) --> ParcelResponse
2 (nested DTOs) (flat columns) (nested DTOs)

Request DTOs use nested objects for readability. The entity flattens them into columns for storage. The response DTO nests them again for the client.

Summary

In this section, you built:

  • Value object DTOs for weight, dimensions, declared value, and content items
  • A tracking number generator using the PKT prefix format
  • An estimated delivery calculator that adds business days
  • A service layer with IParcelService and ParcelService implementing business logic
  • A thin controller that delegates to the service and handles HTTP concerns
  • Manual mapping between nested DTOs and flat entity columns

The service layer keeps business logic separate from HTTP routing, making the code more testable and maintainable. Next, you'll look at validating the request DTOs and handling edge cases.