Foreign Key Validation and Initial Event
When a parcel references a shipper address and a recipient address, the API must verify that both addresses exist before creating the parcel. This section covers how to validate foreign key references, return meaningful errors when they're missing, and create the initial tracking event atomically with the parcel.
Why Validate Foreign Keys in the API Layer?
The database enforces foreign key constraints, so if you try to insert a parcel with a non-existent address ID, the database will throw an exception. Why not just let that happen?
There are three reasons to validate explicitly:
-
Better error messages -- a database constraint violation produces a generic error. An explicit check lets you return "Shipper address abc123 not found" instead of "FK_Parcels_Addresses_ShipperAddressId constraint violated."
-
Correct HTTP status codes -- a constraint violation would result in a 500 Internal Server Error. An explicit check returns 404 Not Found, which is semantically correct.
-
Early failure -- checking addresses before building the parcel entity avoids wasted work. If the shipper address doesn't exist, there's no point generating a tracking number or calculating delivery dates.
Checking Address Existence
The simplest approach uses FindAsync to load each address by its primary key:
csharp1var shipperAddress = await _context.Addresses2 .FindAsync(request.ShipperAddressId);34if (shipperAddress is null)5 return NotFound($"Shipper address {request.ShipperAddressId} not found.");67var recipientAddress = await _context.Addresses8 .FindAsync(request.RecipientAddressId);910if (recipientAddress is null)11 return NotFound($"Recipient address {request.RecipientAddressId} not found.");
FindAsync first checks the EF Core change tracker (in case the entity is already loaded), then queries the database by primary key. It's the most efficient way to load a single entity by ID.
Checking Existence Without Loading
If you don't need the address data itself, AnyAsync is lighter:
csharp1var shipperExists = await _context.Addresses2 .AnyAsync(a => a.Id == request.ShipperAddressId);34if (!shipperExists)5 return NotFound($"Shipper address {request.ShipperAddressId} not found.");
AnyAsync translates to SELECT TOP(1) 1 FROM Addresses WHERE Id = @id -- it doesn't load the full entity. This matters when the entity has many columns.
For our parcel registration, either approach works. We'll use FindAsync because it's simpler and the Address entity is not large.
Parallel vs Sequential Validation
The current code checks the shipper address first, then the recipient address. This means two sequential database round trips. You could parallelize them:
csharp1var shipperTask = _context.Addresses.FindAsync(request.ShipperAddressId);2var recipientTask = _context.Addresses.FindAsync(request.RecipientAddressId);34// Note: FindAsync returns ValueTask, need to convert5var shipperAddress = await shipperTask;6var recipientAddress = await recipientTask;
However, there's a catch with EF Core. A DbContext is not thread-safe. You cannot execute two queries simultaneously on the same context instance. The code above would throw an InvalidOperationException.
To parallelize safely, you'd need separate DbContext instances, which adds complexity. For two simple primary key lookups, sequential execution is the right choice. The overhead of two round trips on a primary key index is negligible.
When Parallel Validation Makes Sense
Parallel validation is worth the complexity when:
- Validation involves external service calls (not just database lookups)
- There are many references to validate (5+, not just 2)
- Each validation is slow (complex queries, network latency)
For our case, sequential is the clear winner.
Returning Structured Errors
The simple string message works, but a structured error response is more useful for API consumers. ASP.NET Core's ProblemDetails provides a standard format:
csharp1if (shipperAddress is null)2{3 return NotFound(new ProblemDetails4 {5 Title = "Address not found",6 Detail = $"Shipper address with ID '{request.ShipperAddressId}' does not exist.",7 Status = StatusCodes.Status404NotFound,8 Instance = HttpContext.Request.Path9 });10}
This returns a JSON response following RFC 9457:
json1{2 "title": "Address not found",3 "detail": "Shipper address with ID 'a1b2c3d4-...' does not exist.",4 "status": 404,5 "instance": "/api/parcels"6}
ProblemDetails is the standard way to communicate errors in ASP.NET Core APIs. It gives clients a consistent structure to parse error responses.
Validating Content Items
Every parcel must declare at least one content item. Each item requires a valid HS code, positive quantity, and a recognized country of origin code.
HS Code Format Validation
HS codes follow the international format XXXX.XX — four digits, a dot, and two digits. A regex validates this pattern:
csharp1private static readonly Regex HsCodeRegex = new(@"^\d{4}\.\d{2}$");23private static bool IsValidHsCode(string hsCode)4{5 return HsCodeRegex.IsMatch(hsCode);6}
Examples of valid HS codes:
8471.30— Portable computers6110.20— Cotton sweaters8504.40— Static converters (power adapters)
Content Item Validation Rules
Apply these checks before creating the parcel:
csharp1if (request.ContentItems is null || request.ContentItems.Count == 0)2{3 return BadRequest(new ProblemDetails4 {5 Title = "Content items required",6 Detail = "At least one content item must be declared for customs compliance.",7 Status = StatusCodes.Status400BadRequest8 });9}1011foreach (var item in request.ContentItems)12{13 if (!HsCodeRegex.IsMatch(item.HsCode))14 {15 return BadRequest(new ProblemDetails16 {17 Title = "Invalid HS code format",18 Detail = $"HS code '{item.HsCode}' does not match the required format XXXX.XX.",19 Status = StatusCodes.Status400BadRequest20 });21 }2223 if (item.Quantity <= 0)24 {25 return BadRequest(new ProblemDetails26 {27 Title = "Invalid quantity",28 Detail = $"Content item '{item.Description}' must have a quantity greater than zero.",29 Status = StatusCodes.Status400BadRequest30 });31 }3233 if (item.CountryOfOrigin.Length != 2)34 {35 return BadRequest(new ProblemDetails36 {37 Title = "Invalid country of origin",38 Detail = $"Country of origin '{item.CountryOfOrigin}' must be a 2-letter ISO 3166-1 alpha-2 code.",39 Status = StatusCodes.Status400BadRequest40 });41 }42}
These validations happen in the API layer before any database operations, following the same early-failure pattern used for address existence checks.
The TrackingEvent Entity
Each parcel has a history of tracking events. The entity looks like this:
csharp1public class TrackingEvent2{3 public Guid Id { get; set; }4 public Guid ParcelId { get; set; }5 public string Status { get; set; } = string.Empty;6 public string Description { get; set; } = string.Empty;7 public DateTime Timestamp { get; set; }8 public string? Location { get; set; }910 // Navigation property11 public Parcel Parcel { get; set; } = null!;12}
The Location is nullable because the initial event (label creation) may not have a physical location -- the label was created digitally, not at a physical facility.
Creating the Initial Event
At registration time, the parcel and its first tracking event must be created together:
csharp1var now = DateTime.UtcNow;23var parcel = new Parcel4{5 Id = Guid.NewGuid(),6 TrackingNumber = TrackingNumberGenerator.Generate(),7 ShipperAddressId = request.ShipperAddressId,8 RecipientAddressId = request.RecipientAddressId,9 ServiceType = request.ServiceType,10 Status = "LabelCreated",11 Description = request.Description,12 WeightValue = request.Weight.Value,13 WeightUnit = request.Weight.Unit,14 DimensionLength = request.Dimensions.Length,15 DimensionWidth = request.Dimensions.Width,16 DimensionHeight = request.Dimensions.Height,17 DimensionUnit = request.Dimensions.Unit,18 DeclaredValueAmount = request.DeclaredValue.Amount,19 DeclaredValueCurrency = request.DeclaredValue.Currency,20 EstimatedDeliveryDate = DeliveryEstimator21 .EstimateDeliveryDate(request.ServiceType, now),22 CreatedAt = now,23 UpdatedAt = now24};2526var initialEvent = new TrackingEvent27{28 Id = Guid.NewGuid(),29 ParcelId = parcel.Id,30 Status = "LabelCreated",31 Description = "Label created, shipment information sent to carrier",32 Timestamp = now,33 Location = null34};3536_context.Parcels.Add(parcel);37_context.TrackingEvents.Add(initialEvent);38await _context.SaveChangesAsync();
Both entities are added to the context before calling SaveChangesAsync. EF Core wraps everything in a single database transaction.
Understanding EF Core's Implicit Transactions
When you call SaveChangesAsync, EF Core automatically wraps all pending changes in a transaction:
csharp1// These three operations happen in ONE transaction:2_context.Parcels.Add(parcel); // Queued3_context.TrackingEvents.Add(initialEvent); // Queued4await _context.SaveChangesAsync(); // Executes all in a transaction
If the parcel insert succeeds but the tracking event insert fails (for example, due to a constraint violation), the entire transaction is rolled back. The parcel is not persisted. This is exactly the atomicity we need.
When You Need Explicit Transactions
EF Core's implicit transaction covers our case because all changes happen within a single SaveChangesAsync call. You need an explicit transaction when:
- You need to make multiple
SaveChangesAsynccalls that must succeed or fail together - You're mixing EF Core operations with raw SQL
- You need to read data and then write based on it within a transaction scope
For explicit transactions:
csharp1using var transaction = await _context.Database.BeginTransactionAsync();23try4{5 _context.Parcels.Add(parcel);6 await _context.SaveChangesAsync();78 _context.TrackingEvents.Add(initialEvent);9 await _context.SaveChangesAsync();1011 await transaction.CommitAsync();12}13catch14{15 await transaction.RollbackAsync();16 throw;17}
This is unnecessary for our registration endpoint since a single SaveChangesAsync is sufficient, but it's useful to know for more complex workflows.
Using Navigation Properties Instead
An alternative to setting ParcelId explicitly is to use navigation properties:
csharp1var parcel = new Parcel2{3 Id = Guid.NewGuid(),4 TrackingNumber = TrackingNumberGenerator.Generate(),5 // ... other properties6 TrackingEvents = new List<TrackingEvent>7 {8 new TrackingEvent9 {10 Id = Guid.NewGuid(),11 Status = "LabelCreated",12 Description = "Label created, shipment information sent to carrier",13 Timestamp = now,14 Location = null15 }16 }17};1819_context.Parcels.Add(parcel);20await _context.SaveChangesAsync();
When you add the parcel, EF Core automatically detects the tracking event in the navigation property and inserts it too, setting the ParcelId foreign key automatically. This approach is cleaner because:
- You don't need to set
ParcelIdmanually - Only one
Addcall is needed - The relationship is expressed in code the same way it exists in the domain
Handling Duplicate Tracking Numbers
Although collisions are extremely unlikely with 12 alphanumeric characters, a unique constraint on the TrackingNumber column provides a safety net:
csharp1protected override void OnModelCreating(ModelBuilder modelBuilder)2{3 modelBuilder.Entity<Parcel>()4 .HasIndex(p => p.TrackingNumber)5 .IsUnique();6}
If a collision occurs, SaveChangesAsync throws a DbUpdateException. You could catch it and retry with a new tracking number:
csharp1const int maxRetries = 3;23for (var attempt = 0; attempt < maxRetries; attempt++)4{5 parcel.TrackingNumber = TrackingNumberGenerator.Generate();67 try8 {9 _context.Parcels.Add(parcel);10 await _context.SaveChangesAsync();11 break;12 }13 catch (DbUpdateException ex)14 when (ex.InnerException?.Message.Contains("unique") == true)15 {16 if (attempt == maxRetries - 1)17 throw;1819 _context.Entry(parcel).State = EntityState.Detached;20 }21}
In practice, this retry code will never execute. But having the unique constraint means the system fails safely if the astronomically unlikely collision ever happens.
EF Core Configuration for Parcel
The entity configuration sets up the relationships and constraints:
csharp1public class ParcelConfiguration : IEntityTypeConfiguration<Parcel>2{3 public void Configure(EntityTypeBuilder<Parcel> builder)4 {5 builder.HasKey(p => p.Id);67 builder.HasIndex(p => p.TrackingNumber)8 .IsUnique();910 builder.Property(p => p.TrackingNumber)11 .HasMaxLength(15)12 .IsRequired();1314 builder.Property(p => p.ServiceType)15 .HasMaxLength(20)16 .IsRequired();1718 builder.Property(p => p.Status)19 .HasMaxLength(30)20 .IsRequired();2122 builder.HasOne(p => p.ShipperAddress)23 .WithMany()24 .HasForeignKey(p => p.ShipperAddressId)25 .OnDelete(DeleteBehavior.Restrict);2627 builder.HasOne(p => p.RecipientAddress)28 .WithMany()29 .HasForeignKey(p => p.RecipientAddressId)30 .OnDelete(DeleteBehavior.Restrict);3132 builder.HasMany(p => p.TrackingEvents)33 .WithOne(e => e.Parcel)34 .HasForeignKey(e => e.ParcelId)35 .OnDelete(DeleteBehavior.Cascade);36 }37}
Key decisions:
DeleteBehavior.Restricton addresses -- deleting an address that's referenced by a parcel should fail, not cascadeDeleteBehavior.Cascadeon tracking events -- deleting a parcel should delete all its eventsHasMaxLength(15)on tracking number -- 3 (prefix) + 12 (random) = 15 characters exactly
The Complete Registration Flow
Putting it all together, here's the full sequence:
1Client sends POST /api/parcels2 │3 ├─ Validate request body (model binding + validation)4 │5 ├─ Check shipper address exists6 │ └─ 404 if not found7 │8 ├─ Check recipient address exists9 │ └─ 404 if not found10 │11 ├─ Generate tracking number (PKT + 12 chars)12 │13 ├─ Calculate estimated delivery date14 │15 ├─ Create Parcel entity (status = LabelCreated)16 │17 ├─ Create TrackingEvent (initial event)18 │19 ├─ SaveChangesAsync (single transaction)20 │21 └─ Return 201 Created with ParcelResponse
Each step either succeeds and moves to the next, or short-circuits with an appropriate error response. The parcel and tracking event are persisted atomically, so the data is always consistent.
Integration Testing the Registration Endpoint
A test verifies that the full workflow produces the expected result:
csharp1[Fact]2public async Task Register_WithValidAddresses_ReturnsCreatedParcel()3{4 // Arrange5 var shipper = new Address { Id = Guid.NewGuid(), /* ... */ };6 var recipient = new Address { Id = Guid.NewGuid(), /* ... */ };7 _context.Addresses.AddRange(shipper, recipient);8 await _context.SaveChangesAsync();910 var request = new RegisterParcelRequest11 {12 ShipperAddressId = shipper.Id,13 RecipientAddressId = recipient.Id,14 ServiceType = "Express",15 Description = "Test parcel",16 Weight = new WeightDto { Value = 1.5m, Unit = "kg" },17 Dimensions = new DimensionsDto18 {19 Length = 20, Width = 15, Height = 10, Unit = "cm"20 },21 DeclaredValue = new DeclaredValueDto22 {23 Amount = 50.00m, Currency = "USD"24 }25 };2627 // Act28 var response = await _client.PostAsJsonAsync("/api/parcels", request);2930 // Assert31 response.StatusCode.Should().Be(HttpStatusCode.Created);3233 var parcel = await response.Content34 .ReadFromJsonAsync<ParcelResponse>();3536 parcel!.TrackingNumber.Should().StartWith("PKT");37 parcel.TrackingNumber.Should().HaveLength(15);38 parcel.Status.Should().Be("LabelCreated");39 parcel.ServiceType.Should().Be("Express");40}4142[Fact]43public async Task Register_WithMissingShipperAddress_Returns404()44{45 var request = new RegisterParcelRequest46 {47 ShipperAddressId = Guid.NewGuid(), // Does not exist48 RecipientAddressId = Guid.NewGuid(),49 // ... rest of request50 };5152 var response = await _client.PostAsJsonAsync("/api/parcels", request);5354 response.StatusCode.Should().Be(HttpStatusCode.NotFound);55}
These tests verify both the happy path (valid addresses, correct status, tracking number format) and the error case (missing address returns 404).
Summary
In this section, you learned:
- Why explicit foreign key validation produces better API responses than relying on database constraints
- Validating content items — HS code format (
XXXX.XX), at least one required, positive quantities, valid country codes FindAsyncvsAnyAsyncfor checking entity existence- EF Core's implicit transactions ensure the parcel and tracking event are created atomically
- Navigation properties simplify creating related entities
- Entity configuration sets up unique constraints, relationships, and delete behaviors
- Integration tests verify the complete registration workflow