20 minlesson

JSON Patch Endpoint & Terminal State Protection

JSON Patch Endpoint & Terminal State Protection

With the state machine and service layer in place, we now build the HTTP endpoint that allows clients to update a parcel using the JSON Patch standard (RFC 6902). We'll integrate the ParcelStatusService and ensure terminal states are fully protected from any modifications.

Why PATCH, Not PUT?

PUT replaces the entire resource. PATCH applies a partial update. Since we're only changing specific fields (not replacing the whole parcel), PATCH is the correct HTTP method:

1PUT /api/parcels/123 - Replace the entire parcel
2PATCH /api/parcels/123 - Apply partial updates to specific fields

REST convention says: use PATCH when you're modifying a subset of a resource's properties.

What is JSON Patch?

JSON Patch is a standard format (RFC 6902) for describing changes to a JSON document. Instead of sending a custom DTO with the new values, the client sends an array of operations:

json
1[
2 { "op": "replace", "path": "/status", "value": "PickedUp" }
3]

Each operation has three fields:

FieldDescription
opThe operation: replace, add, remove, copy, move, test
pathA JSON Pointer (RFC 6901) to the target property
valueThe new value (for replace, add, test)

The replace operation is the most common for API updates. The test operation is useful for optimistic concurrency — it checks that a field has an expected value before applying other operations:

json
1[
2 { "op": "test", "path": "/status", "value": "LabelCreated" },
3 { "op": "replace", "path": "/status", "value": "PickedUp" }
4]

This patch says: "only change the status to PickedUp if it is currently LabelCreated." If the test fails, the entire patch is rejected.

Installing the JSON Patch Package

ASP.NET Core provides JSON Patch support through a NuGet package:

bash
1dotnet add src/ParcelTracking.Api package Microsoft.AspNetCore.JsonPatch

You also need the Newtonsoft.Json-based input formatter because JsonPatchDocument relies on Newtonsoft for deserialization:

bash
1dotnet add src/ParcelTracking.Api package Microsoft.AspNetCore.Mvc.NewtonsoftJson

Register it in Program.cs:

csharp
1builder.Services.AddControllers()
2 .AddNewtonsoftJson();

This adds Newtonsoft.Json as an input/output formatter alongside the default System.Text.Json formatter. JsonPatchDocument requires Newtonsoft because the System.Text.Json serializer does not yet support it.

Building the PATCH Endpoint

Here's the controller action that accepts a JSON Patch document and applies it to a parcel:

csharp
1[ApiController]
2[Route("api/[controller]")]
3public class ParcelsController : ControllerBase
4{
5 private readonly ParcelStatusService _statusService;
6 private readonly ParcelTrackingDbContext _db;
7
8 public ParcelsController(
9 ParcelStatusService statusService,
10 ParcelTrackingDbContext db)
11 {
12 _statusService = statusService;
13 _db = db;
14 }
15
16 [HttpPatch("{id:guid}")]
17 [ProducesResponseType(typeof(ParcelResponse), StatusCodes.Status200OK)]
18 [ProducesResponseType(StatusCodes.Status404NotFound)]
19 [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
20 public async Task<IActionResult> Patch(
21 Guid id, [FromBody] JsonPatchDocument<ParcelPatchModel> patchDoc)
22 {
23 var parcel = await _parcelService.GetWritableParcelAsync(id);
24
25 if (parcel is null)
26 {
27 return NotFound(new
28 {
29 error = "not_found",
30 message = $"Parcel with ID '{id}' was not found"
31 });
32 }
33
34 // Map entity to a patchable model
35 var patchModel = new ParcelPatchModel
36 {
37 Status = parcel.Status,
38 ServiceType = parcel.ServiceType,
39 Description = parcel.Description,
40 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate
41 };
42
43 // Apply the patch operations
44 patchDoc.ApplyTo(patchModel, ModelState);
45
46 if (!ModelState.IsValid)
47 {
48 return UnprocessableEntity(new
49 {
50 error = "invalid_patch",
51 message = "The patch document contains invalid operations",
52 errors = ModelState.Values
53 .SelectMany(v => v.Errors)
54 .Select(e => e.ErrorMessage)
55 });
56 }
57
58 // Validate status transition if status changed
59 if (patchModel.Status != parcel.Status)
60 {
61 var result = await _statusService.TransitionStatusAsync(
62 id, patchModel.Status);
63
64 return ToActionResult(result);
65 }
66
67 // Apply non-status changes
68 parcel.ServiceType = patchModel.ServiceType;
69 parcel.Description = patchModel.Description;
70 parcel.EstimatedDeliveryDate = patchModel.EstimatedDeliveryDate;
71 parcel.UpdatedAt = DateTimeOffset.UtcNow;
72
73 await _db.SaveChangesAsync();
74
75 return Ok(MapToResponse(parcel));
76 }
77}

The Patch Model

Instead of applying the patch directly to the EF Core entity, we use an intermediate model. This controls exactly which fields clients can modify:

csharp
1public class ParcelPatchModel
2{
3 [JsonConverter(typeof(StringEnumConverter))]
4 public ParcelStatus Status { get; set; }
5
6 [JsonConverter(typeof(StringEnumConverter))]
7 public ServiceType ServiceType { get; set; }
8
9 [MaxLength(500)]
10 public string? Description { get; set; }
11
12 public DateTimeOffset? EstimatedDeliveryDate { get; set; }
13}

The patch model is a whitelist. Properties not in this model (like Id, TrackingNumber, CreatedAt) cannot be modified through the PATCH endpoint, even if the client tries. The StringEnumConverter attributes ensure clients use string names like "PickedUp" rather than integer values.

Why Not Patch the Entity Directly?

Applying a JsonPatchDocument<Parcel> directly to the entity is dangerous:

  • Clients could modify Id, TrackingNumber, or CreatedAt
  • Navigation properties could be overwritten
  • Foreign key values could be changed to bypass business rules

The intermediate model acts as a security boundary between the HTTP request and the domain model.

Request and Response Examples

Successful Status Update

http
1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6
2Content-Type: application/json-patch+json
3
4[
5 { "op": "replace", "path": "/status", "value": "PickedUp" }
6]
http
1HTTP/1.1 200 OK
2Content-Type: application/json
3
4{
5 "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
6 "trackingNumber": "PKG-20250215-001",
7 "status": "PickedUp",
8 "updatedAt": "2025-02-15T14:30:00Z"
9}

Note the content type: application/json-patch+json. This is the standard media type for JSON Patch documents. ASP.NET Core accepts both application/json-patch+json and application/json for patch requests.

Updating Multiple Fields

http
1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6
2Content-Type: application/json-patch+json
3
4[
5 { "op": "replace", "path": "/description", "value": "Fragile electronics" },
6 { "op": "replace", "path": "/serviceType", "value": "Express" }
7]

Multiple operations are applied atomically — either all succeed or none do.

Invalid Transition

http
1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6
2Content-Type: application/json-patch+json
3
4[
5 { "op": "replace", "path": "/status", "value": "Delivered" }
6]
http
1HTTP/1.1 422 Unprocessable Entity
2Content-Type: application/json
3
4{
5 "error": "invalid_transition",
6 "message": "Cannot transition from 'LabelCreated' to 'Delivered'",
7 "currentStatus": "LabelCreated",
8 "requestedStatus": "Delivered",
9 "allowedStatuses": ["PickedUp", "Exception"]
10}

Terminal State Rejection

http
1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6
2Content-Type: application/json-patch+json
3
4[
5 { "op": "replace", "path": "/status", "value": "InTransit" }
6]
http
1HTTP/1.1 422 Unprocessable Entity
2Content-Type: application/json
3
4{
5 "error": "terminal_state",
6 "message": "Parcel is in terminal state 'Delivered' and cannot be modified",
7 "currentStatus": "Delivered"
8}

Invalid Patch Operation

http
1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6
2Content-Type: application/json-patch+json
3
4[
5 { "op": "replace", "path": "/trackingNumber", "value": "HACKED" }
6]
http
1HTTP/1.1 422 Unprocessable Entity
2Content-Type: application/json
3
4{
5 "error": "invalid_patch",
6 "message": "The patch document contains invalid operations",
7 "errors": ["The target location specified by path '/trackingNumber' was not found."]
8}

Because trackingNumber is not on the ParcelPatchModel, the operation fails. The patch model whitelist protects read-only fields.

Protecting Terminal States from All Modifications

Status transitions are not the only way someone might try to modify a parcel. Other endpoints (like updating the sender address or parcel weight) must also respect terminal states.

Add a guard check that runs before any modification:

csharp
1public class ParcelService
2{
3 private readonly ParcelRepository _repository;
4
5 public async Task<Parcel?> GetWritableParcelAsync(Guid parcelId)
6 {
7 var parcel = await _repository.GetByIdAsync(parcelId);
8
9 if (parcel is null)
10 {
11 return null;
12 }
13
14 if (ParcelStatusRules.IsTerminal(parcel.Status))
15 {
16 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);
17 }
18
19 return parcel;
20 }
21}

Any endpoint that modifies a parcel first calls GetWritableParcelAsync. If the parcel is in a terminal state, the request is rejected before any changes are attempted.

Custom Exception for Terminal State Violations

Define a specific exception type for this business rule:

csharp
1public class ParcelInTerminalStateException : System.Exception
2{
3 public Guid ParcelId { get; }
4 public ParcelStatus Status { get; }
5
6 public ParcelInTerminalStateException(Guid parcelId, ParcelStatus status)
7 : base($"Parcel {parcelId} is in terminal state '{status}' and cannot be modified")
8 {
9 ParcelId = parcelId;
10 Status = status;
11 }
12}

Handle this exception globally with an exception handler:

csharp
1public class TerminalStateExceptionHandler : IExceptionHandler
2{
3 public async ValueTask<bool> TryHandleAsync(
4 HttpContext httpContext,
5 System.Exception exception,
6 CancellationToken cancellationToken)
7 {
8 if (exception is not ParcelInTerminalStateException terminalEx)
9 {
10 return false;
11 }
12
13 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
14 await httpContext.Response.WriteAsJsonAsync(new
15 {
16 error = "terminal_state",
17 message = terminalEx.Message,
18 parcelId = terminalEx.ParcelId,
19 currentStatus = terminalEx.Status.ToString()
20 }, cancellationToken);
21
22 return true;
23 }
24}

Register it in Program.cs:

csharp
1builder.Services.AddExceptionHandler<TerminalStateExceptionHandler>();

Now every endpoint that attempts to modify a terminal parcel gets a consistent 422 response, without duplicating the check in each controller action.

Using the Guard with Other Operations

When other endpoints need to modify parcels, they follow the same guard pattern:

csharp
1[HttpPut("{id:guid}/address")]
2public async Task<IActionResult> UpdateAddress(
3 Guid id, [FromBody] UpdateAddressRequest request)
4{
5 // GetWritableParcelAsync throws if terminal
6 var parcel = await _parcelService.GetWritableParcelAsync(id);
7
8 if (parcel is null)
9 {
10 return NotFound();
11 }
12
13 parcel.RecipientAddress = request.Address;
14 parcel.UpdatedAt = DateTimeOffset.UtcNow;
15 await _repository.UpdateAsync(parcel);
16
17 return Ok(MapToResponse(parcel));
18}

The GetWritableParcelAsync call ensures the terminal state check happens automatically. The exception handler converts it to a proper 422 response.

Adding a Tracking Event on Status Change

When a status transition succeeds, you should also record a tracking event for the audit trail:

csharp
1public async Task<StatusTransitionResult> TransitionStatusAsync(
2 Guid parcelId, ParcelStatus newStatus)
3{
4 var parcel = await _repository.GetByIdAsync(parcelId);
5
6 if (parcel is null)
7 return StatusTransitionResult.NotFound(parcelId);
8
9 if (ParcelStatusRules.IsTerminal(parcel.Status))
10 return StatusTransitionResult.TerminalState(parcel.Status);
11
12 if (!ParcelStatusRules.CanTransition(parcel.Status, newStatus))
13 return StatusTransitionResult.InvalidTransition(
14 parcel.Status, newStatus,
15 ParcelStatusRules.GetAllowedTransitions(parcel.Status));
16
17 var previousStatus = parcel.Status;
18 parcel.Status = newStatus;
19 parcel.UpdatedAt = DateTimeOffset.UtcNow;
20 await _repository.UpdateAsync(parcel);
21
22 // Record the transition as a tracking event
23 var trackingEvent = new TrackingEvent
24 {
25 ParcelId = parcelId,
26 Status = newStatus,
27 Description = $"Status changed from {previousStatus} to {newStatus}",
28 Timestamp = DateTimeOffset.UtcNow
29 };
30 await _trackingEventRepository.AddAsync(trackingEvent);
31
32 return StatusTransitionResult.Success(parcel);
33}

This creates a complete audit trail of every status change, which is valuable for both debugging and customer-facing tracking pages.

Testing the Endpoint

Integration tests verify the full HTTP pipeline. Note that test requests use SendAsync with HttpMethod.Patch and the JSON Patch content type:

csharp
1private async Task<HttpResponseMessage> PatchParcel(
2 Guid id, params object[] operations)
3{
4 var content = new StringContent(
5 JsonConvert.SerializeObject(operations),
6 Encoding.UTF8,
7 "application/json-patch+json");
8
9 return await _client.SendAsync(
10 new HttpRequestMessage(HttpMethod.Patch, $"/api/parcels/{id}")
11 {
12 Content = content
13 });
14}
15
16[Fact]
17public async Task Patch_ValidStatusTransition_Returns200()
18{
19 // Arrange: create a parcel in LabelCreated state
20 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);
21
22 // Act
23 var response = await PatchParcel(parcelId,
24 new { op = "replace", path = "/status", value = "PickedUp" });
25
26 // Assert
27 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
28 var body = await response.Content
29 .ReadFromJsonAsync<ParcelResponse>();
30 Assert.Equal("PickedUp", body!.Status);
31}
32
33[Fact]
34public async Task Patch_InvalidTransition_Returns422()
35{
36 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);
37
38 var response = await PatchParcel(parcelId,
39 new { op = "replace", path = "/status", value = "Delivered" });
40
41 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
42}
43
44[Fact]
45public async Task Patch_TerminalState_Returns422()
46{
47 var parcelId = await CreateTestParcel(ParcelStatus.Delivered);
48
49 var response = await PatchParcel(parcelId,
50 new { op = "replace", path = "/status", value = "InTransit" });
51
52 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
53}
54
55[Fact]
56public async Task Patch_ReadOnlyField_Returns422()
57{
58 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);
59
60 var response = await PatchParcel(parcelId,
61 new { op = "replace", path = "/trackingNumber", value = "HACKED" });
62
63 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
64}
65
66[Fact]
67public async Task UpdateAddress_TerminalState_Returns422()
68{
69 var parcelId = await CreateTestParcel(ParcelStatus.Returned);
70
71 var response = await _client.PutAsJsonAsync(
72 $"/api/parcels/{parcelId}/address",
73 new { address = "123 New St" });
74
75 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
76}

Swagger Documentation

The [ProducesResponseType] attributes on the controller action ensure Swagger documents all possible responses:

csharp
1[HttpPatch("{id:guid}")]
2[Consumes("application/json-patch+json")]
3[ProducesResponseType(typeof(ParcelResponse), StatusCodes.Status200OK)]
4[ProducesResponseType(StatusCodes.Status404NotFound)]
5[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
6public async Task<IActionResult> Patch(
7 Guid id, [FromBody] JsonPatchDocument<ParcelPatchModel> patchDoc)

The [Consumes] attribute tells Swagger that this endpoint expects the application/json-patch+json content type. This tells API consumers up front what format to use and what responses to expect.

Error Response Consistency

All business rule violations in the API follow the same shape:

json
1{
2 "error": "string (error type identifier)",
3 "message": "string (human-readable description)",
4 "...": "additional context fields"
5}

This consistency means clients can write a single error handler that works across all endpoints, checking the error field to determine the specific failure type.

Key Takeaways

  • Use JSON Patch (RFC 6902) for partial resource updates with a standard [{ "op", "path", "value" }] format
  • Install Microsoft.AspNetCore.JsonPatch and Microsoft.AspNetCore.Mvc.NewtonsoftJson for JSON Patch support
  • Apply patches to an intermediate model (not the entity) to control which fields are modifiable
  • The test operation enables optimistic concurrency checks within the patch document
  • Validate status transitions through the service layer when the patch modifies the status field
  • Protect terminal states across all modification endpoints, not just the status endpoint
  • Use a global exception handler for consistent terminal state error responses
  • Record tracking events when status transitions succeed for audit purposes
  • Use [Consumes("application/json-patch+json")] and [ProducesResponseType] for Swagger documentation