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.
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/:
csharp1using ParcelTracking.Application.DTOs.Parcels;23namespace ParcelTracking.Application.Services;45public interface IParcelService6{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/:
csharp1using Microsoft.EntityFrameworkCore;2using Microsoft.Extensions.Logging;3using ParcelTracking.Application.DTOs.Parcels;4using ParcelTracking.Domain.Entities;5using ParcelTracking.Infrastructure.Data;67namespace ParcelTracking.Application.Services;89public class ParcelService : IParcelService10{11 private readonly ParcelTrackingDbContext _db;12 private readonly ILogger<ParcelService> _logger;1314 public ParcelService(15 ParcelTrackingDbContext db,16 ILogger<ParcelService> logger)17 {18 _db = db;19 _logger = logger;20 }2122 public async Task<ParcelResponse> RegisterAsync(RegisterParcelRequest request)23 {24 // Step 1: Validate that referenced addresses exist25 var shipperAddress = await _db.Addresses26 .FindAsync(request.ShipperAddressId);2728 if (shipperAddress is null)29 throw new InvalidOperationException(30 $"Shipper address {request.ShipperAddressId} not found.");3132 var recipientAddress = await _db.Addresses33 .FindAsync(request.RecipientAddressId);3435 if (recipientAddress is null)36 throw new InvalidOperationException(37 $"Recipient address {request.RecipientAddressId} not found.");3839 // Step 2: Generate tracking number40 var trackingNumber = TrackingNumberGenerator.Generate();4142 // Step 3: Calculate estimated delivery43 var now = DateTime.UtcNow;44 var estimatedDelivery = DeliveryEstimator45 .EstimateDeliveryDate(request.ServiceType, now);4647 // Step 4: Create the parcel entity48 var parcel = new Parcel49 {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 ParcelContentItem69 {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.CountryOfOrigin79 }).ToList()80 };8182 // Step 5: Create the initial tracking event83 var trackingEvent = new TrackingEvent84 {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 = null91 };9293 // Step 6: Persist both in a single transaction94 _db.Parcels.Add(parcel);95 _db.TrackingEvents.Add(trackingEvent);96 await _db.SaveChangesAsync();9798 _logger.LogInformation(99 "Registered parcel {TrackingNumber} with ID {ParcelId}",100 parcel.TrackingNumber, parcel.Id);101102 // Step 7: Return the response103 return MapToResponse(parcel);104 }105106 private static ParcelResponse MapToResponse(Parcel parcel)107 {108 return new ParcelResponse109 {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 WeightDto118 {119 Value = parcel.WeightValue,120 Unit = parcel.WeightUnit121 },122 Dimensions = new DimensionsDto123 {124 Length = parcel.DimensionLength,125 Width = parcel.DimensionWidth,126 Height = parcel.DimensionHeight,127 Unit = parcel.DimensionUnit128 },129 DeclaredValue = new DeclaredValueDto130 {131 Amount = parcel.DeclaredValueAmount,132 Currency = parcel.DeclaredValueCurrency133 },134 ContentItems = parcel.ContentItems.Select(ci => new ContentItemDto135 {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.CountryOfOrigin144 }).ToList(),145 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate,146 CreatedAt = parcel.CreatedAt147 };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:
csharp1using Microsoft.AspNetCore.Mvc;2using ParcelTracking.Application.DTOs.Parcels;3using ParcelTracking.Application.Services;45namespace ParcelTracking.Api.Controllers;67[ApiController]8[Route("api/[controller]")]9public class ParcelsController : ControllerBase10{11 private readonly IParcelService _parcelService;12 private readonly ILogger<ParcelsController> _logger;1314 public ParcelsController(15 IParcelService parcelService,16 ILogger<ParcelsController> logger)17 {18 _parcelService = parcelService;19 _logger = logger;20 }2122 [HttpPost]23 [ProducesResponseType(typeof(ParcelResponse), StatusCodes.Status201Created)]24 [ProducesResponseType(StatusCodes.Status404NotFound)]25 public async Task<IActionResult> Register(RegisterParcelRequest request)26 {27 try28 {29 var response = await _parcelService.RegisterAsync(request);3031 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:
- Accept the HTTP request and bind it to
RegisterParcelRequest - Call the service
- Catch exceptions and translate them to HTTP status codes
- 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:
csharp1// Register services2builder.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:
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 service layer with
IParcelServiceandParcelServiceimplementing 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.