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.

The Controller Action

With the building blocks in place, the controller action ties everything together:

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

Key Design Decisions

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

  2. Single SaveChangesAsync -- both the parcel and the tracking event are added before calling save. EF Core wraps everything in one transaction automatically.

  3. UTC timestamps -- all dates use DateTime.UtcNow to avoid timezone issues.

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 controller action that orchestrates the full registration workflow
  • Manual mapping between nested DTOs and flat entity columns

Next, you'll look at validating the foreign key references and handling the case where addresses don't exist.