18 minlesson

Foreign Key Validation and Initial Event

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:

  1. 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."

  2. 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.

  3. 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:

csharp
1var shipperAddress = await _context.Addresses
2 .FindAsync(request.ShipperAddressId);
3
4if (shipperAddress is null)
5 return NotFound($"Shipper address {request.ShipperAddressId} not found.");
6
7var recipientAddress = await _context.Addresses
8 .FindAsync(request.RecipientAddressId);
9
10if (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:

csharp
1var shipperExists = await _context.Addresses
2 .AnyAsync(a => a.Id == request.ShipperAddressId);
3
4if (!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:

csharp
1var shipperTask = _context.Addresses.FindAsync(request.ShipperAddressId);
2var recipientTask = _context.Addresses.FindAsync(request.RecipientAddressId);
3
4// Note: FindAsync returns ValueTask, need to convert
5var 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:

csharp
1if (shipperAddress is null)
2{
3 return NotFound(new ProblemDetails
4 {
5 Title = "Address not found",
6 Detail = $"Shipper address with ID '{request.ShipperAddressId}' does not exist.",
7 Status = StatusCodes.Status404NotFound,
8 Instance = HttpContext.Request.Path
9 });
10}

This returns a JSON response following RFC 9457:

json
1{
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:

csharp
1private static readonly Regex HsCodeRegex = new(@"^\d{4}\.\d{2}$");
2
3private static bool IsValidHsCode(string hsCode)
4{
5 return HsCodeRegex.IsMatch(hsCode);
6}

Examples of valid HS codes:

  • 8471.30 — Portable computers
  • 6110.20 — Cotton sweaters
  • 8504.40 — Static converters (power adapters)

Content Item Validation Rules

Apply these checks before creating the parcel:

csharp
1if (request.ContentItems is null || request.ContentItems.Count == 0)
2{
3 return BadRequest(new ProblemDetails
4 {
5 Title = "Content items required",
6 Detail = "At least one content item must be declared for customs compliance.",
7 Status = StatusCodes.Status400BadRequest
8 });
9}
10
11foreach (var item in request.ContentItems)
12{
13 if (!HsCodeRegex.IsMatch(item.HsCode))
14 {
15 return BadRequest(new ProblemDetails
16 {
17 Title = "Invalid HS code format",
18 Detail = $"HS code '{item.HsCode}' does not match the required format XXXX.XX.",
19 Status = StatusCodes.Status400BadRequest
20 });
21 }
22
23 if (item.Quantity <= 0)
24 {
25 return BadRequest(new ProblemDetails
26 {
27 Title = "Invalid quantity",
28 Detail = $"Content item '{item.Description}' must have a quantity greater than zero.",
29 Status = StatusCodes.Status400BadRequest
30 });
31 }
32
33 if (item.CountryOfOrigin.Length != 2)
34 {
35 return BadRequest(new ProblemDetails
36 {
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.Status400BadRequest
40 });
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:

csharp
1public class TrackingEvent
2{
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; }
9
10 // Navigation property
11 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:

csharp
1var now = DateTime.UtcNow;
2
3var parcel = new Parcel
4{
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 = DeliveryEstimator
21 .EstimateDeliveryDate(request.ServiceType, now),
22 CreatedAt = now,
23 UpdatedAt = now
24};
25
26var initialEvent = new TrackingEvent
27{
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 = null
34};
35
36_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:

csharp
1// These three operations happen in ONE transaction:
2_context.Parcels.Add(parcel); // Queued
3_context.TrackingEvents.Add(initialEvent); // Queued
4await _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 SaveChangesAsync calls 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:

csharp
1using var transaction = await _context.Database.BeginTransactionAsync();
2
3try
4{
5 _context.Parcels.Add(parcel);
6 await _context.SaveChangesAsync();
7
8 _context.TrackingEvents.Add(initialEvent);
9 await _context.SaveChangesAsync();
10
11 await transaction.CommitAsync();
12}
13catch
14{
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:

csharp
1var parcel = new Parcel
2{
3 Id = Guid.NewGuid(),
4 TrackingNumber = TrackingNumberGenerator.Generate(),
5 // ... other properties
6 TrackingEvents = new List<TrackingEvent>
7 {
8 new TrackingEvent
9 {
10 Id = Guid.NewGuid(),
11 Status = "LabelCreated",
12 Description = "Label created, shipment information sent to carrier",
13 Timestamp = now,
14 Location = null
15 }
16 }
17};
18
19_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 ParcelId manually
  • Only one Add call 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:

csharp
1protected 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:

csharp
1const int maxRetries = 3;
2
3for (var attempt = 0; attempt < maxRetries; attempt++)
4{
5 parcel.TrackingNumber = TrackingNumberGenerator.Generate();
6
7 try
8 {
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;
18
19 _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:

csharp
1public class ParcelConfiguration : IEntityTypeConfiguration<Parcel>
2{
3 public void Configure(EntityTypeBuilder<Parcel> builder)
4 {
5 builder.HasKey(p => p.Id);
6
7 builder.HasIndex(p => p.TrackingNumber)
8 .IsUnique();
9
10 builder.Property(p => p.TrackingNumber)
11 .HasMaxLength(15)
12 .IsRequired();
13
14 builder.Property(p => p.ServiceType)
15 .HasMaxLength(20)
16 .IsRequired();
17
18 builder.Property(p => p.Status)
19 .HasMaxLength(30)
20 .IsRequired();
21
22 builder.HasOne(p => p.ShipperAddress)
23 .WithMany()
24 .HasForeignKey(p => p.ShipperAddressId)
25 .OnDelete(DeleteBehavior.Restrict);
26
27 builder.HasOne(p => p.RecipientAddress)
28 .WithMany()
29 .HasForeignKey(p => p.RecipientAddressId)
30 .OnDelete(DeleteBehavior.Restrict);
31
32 builder.HasMany(p => p.TrackingEvents)
33 .WithOne(e => e.Parcel)
34 .HasForeignKey(e => e.ParcelId)
35 .OnDelete(DeleteBehavior.Cascade);
36 }
37}

Key decisions:

  • DeleteBehavior.Restrict on addresses -- deleting an address that's referenced by a parcel should fail, not cascade
  • DeleteBehavior.Cascade on tracking events -- deleting a parcel should delete all its events
  • HasMaxLength(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/parcels
2
3 ├─ Validate request body (model binding + validation)
4
5 ├─ Check shipper address exists
6 │ └─ 404 if not found
7
8 ├─ Check recipient address exists
9 │ └─ 404 if not found
10
11 ├─ Generate tracking number (PKT + 12 chars)
12
13 ├─ Calculate estimated delivery date
14
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:

csharp
1[Fact]
2public async Task Register_WithValidAddresses_ReturnsCreatedParcel()
3{
4 // Arrange
5 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();
9
10 var request = new RegisterParcelRequest
11 {
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 DimensionsDto
18 {
19 Length = 20, Width = 15, Height = 10, Unit = "cm"
20 },
21 DeclaredValue = new DeclaredValueDto
22 {
23 Amount = 50.00m, Currency = "USD"
24 }
25 };
26
27 // Act
28 var response = await _client.PostAsJsonAsync("/api/parcels", request);
29
30 // Assert
31 response.StatusCode.Should().Be(HttpStatusCode.Created);
32
33 var parcel = await response.Content
34 .ReadFromJsonAsync<ParcelResponse>();
35
36 parcel!.TrackingNumber.Should().StartWith("PKT");
37 parcel.TrackingNumber.Should().HaveLength(15);
38 parcel.Status.Should().Be("LabelCreated");
39 parcel.ServiceType.Should().Be("Express");
40}
41
42[Fact]
43public async Task Register_WithMissingShipperAddress_Returns404()
44{
45 var request = new RegisterParcelRequest
46 {
47 ShipperAddressId = Guid.NewGuid(), // Does not exist
48 RecipientAddressId = Guid.NewGuid(),
49 // ... rest of request
50 };
51
52 var response = await _client.PostAsJsonAsync("/api/parcels", request);
53
54 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
  • FindAsync vs AnyAsync for 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