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:
csharp1public class WeightDto2{3 public decimal Value { get; set; }4 public string Unit { get; set; } = string.Empty; // "kg", "lb"5}67public class DimensionsDto8{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}1415public class DeclaredValueDto16{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:
csharp1public class ContentItemDto2{3 public string HsCode { get; set; } = string.Empty; // Format: XXXX.XX4 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 42178 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-211}
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
csharp1public class RegisterParcelRequest2{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
csharp1public class ParcelResponse2{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:
csharp1public class Parcel2{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;1011 // Weight12 public decimal WeightValue { get; set; }13 public string WeightUnit { get; set; } = string.Empty;1415 // Dimensions16 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;2021 // Declared Value22 public decimal DeclaredValueAmount { get; set; }23 public string DeclaredValueCurrency { get; set; } = string.Empty;2425 public DateTime EstimatedDeliveryDate { get; set; }26 public DateTime CreatedAt { get; set; }27 public DateTime UpdatedAt { get; set; }2829 // Navigation properties30 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:
csharp1public static class TrackingNumberGenerator2{3 private const string Prefix = "PKT";4 private const string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";5 private const int RandomLength = 12;67 public static string Generate()8 {9 return Prefix + RandomString(RandomLength);10 }1112 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:
csharp1public static class DeliveryEstimator2{3 private static readonly Dictionary<string, int> TransitDays = new()4 {5 ["Standard"] = 7,6 ["Express"] = 3,7 ["Overnight"] = 1,8 ["Economy"] = 109 };1011 public static DateTime EstimateDeliveryDate(12 string serviceType,13 DateTime registrationDate)14 {15 var days = TransitDays.GetValueOrDefault(serviceType, 7);16 return AddBusinessDays(registrationDate, days);17 }1819 private static DateTime AddBusinessDays(DateTime start, int days)20 {21 var current = start;22 var added = 0;2324 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 }3334 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:
csharp1[ApiController]2[Route("api/[controller]")]3public class ParcelsController : ControllerBase4{5 private readonly ParcelTrackingDbContext _context;67 public ParcelsController(ParcelTrackingDbContext context)8 {9 _context = context;10 }1112 [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 exist18 var shipperAddress = await _context.Addresses19 .FindAsync(request.ShipperAddressId);2021 if (shipperAddress is null)22 return NotFound($"Shipper address {request.ShipperAddressId} not found.");2324 var recipientAddress = await _context.Addresses25 .FindAsync(request.RecipientAddressId);2627 if (recipientAddress is null)28 return NotFound($"Recipient address {request.RecipientAddressId} not found.");2930 // Step 2: Generate tracking number31 var trackingNumber = TrackingNumberGenerator.Generate();3233 // Step 3: Calculate estimated delivery34 var now = DateTime.UtcNow;35 var estimatedDelivery = DeliveryEstimator36 .EstimateDeliveryDate(request.ServiceType, now);3738 // Step 4: Create the parcel entity39 var parcel = new Parcel40 {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 ParcelContentItem60 {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.CountryOfOrigin70 }).ToList()71 };7273 // Step 5: Create the initial tracking event74 var trackingEvent = new TrackingEvent75 {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 = null82 };8384 // Step 6: Persist both in a single transaction85 _context.Parcels.Add(parcel);86 _context.TrackingEvents.Add(trackingEvent);87 await _context.SaveChangesAsync();8889 // Step 7: Return the response90 var response = MapToResponse(parcel);9192 return CreatedAtAction(93 nameof(GetByTrackingNumber),94 new { trackingNumber = parcel.TrackingNumber },95 response);96 }9798 private static ParcelResponse MapToResponse(Parcel parcel)99 {100 return new ParcelResponse101 {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 WeightDto110 {111 Value = parcel.WeightValue,112 Unit = parcel.WeightUnit113 },114 Dimensions = new DimensionsDto115 {116 Length = parcel.DimensionLength,117 Width = parcel.DimensionWidth,118 Height = parcel.DimensionHeight,119 Unit = parcel.DimensionUnit120 },121 DeclaredValue = new DeclaredValueDto122 {123 Amount = parcel.DeclaredValueAmount,124 Currency = parcel.DeclaredValueCurrency125 },126 ContentItems = parcel.ContentItems.Select(ci => new ContentItemDto127 {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.CountryOfOrigin136 }).ToList(),137 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate,138 CreatedAt = parcel.CreatedAt139 };140 }141}
Key Design Decisions
-
CreatedAtActionreturn -- returns HTTP 201 with aLocationheader pointing to the GET endpoint for the new parcel. This follows REST conventions. -
Single
SaveChangesAsync-- both the parcel and the tracking event are added before calling save. EF Core wraps everything in one transaction automatically. -
UTC timestamps -- all dates use
DateTime.UtcNowto avoid timezone issues.
Testing with an HTTP Request
Here's what the registration request looks like:
http1POST /api/parcels HTTP/1.12Content-Type: application/json34{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:
http1HTTP/1.1 201 Created2Location: /api/parcels/PKT7G4KM2XB9NQ13Content-Type: application/json45{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) --> ParcelResponse2 (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.