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 parcel2PATCH /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:
json1[2 { "op": "replace", "path": "/status", "value": "PickedUp" }3]
Each operation has three fields:
| Field | Description |
|---|---|
op | The operation: replace, add, remove, copy, move, test |
path | A JSON Pointer (RFC 6901) to the target property |
value | The 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:
json1[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:
bash1dotnet add src/ParcelTracking.Api package Microsoft.AspNetCore.JsonPatch
You also need the Newtonsoft.Json-based input formatter because JsonPatchDocument relies on Newtonsoft for deserialization:
bash1dotnet add src/ParcelTracking.Api package Microsoft.AspNetCore.Mvc.NewtonsoftJson
Register it in Program.cs:
csharp1builder.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:
csharp1[ApiController]2[Route("api/[controller]")]3public class ParcelsController : ControllerBase4{5 private readonly ParcelStatusService _statusService;6 private readonly ParcelTrackingDbContext _db;78 public ParcelsController(9 ParcelStatusService statusService,10 ParcelTrackingDbContext db)11 {12 _statusService = statusService;13 _db = db;14 }1516 [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);2425 if (parcel is null)26 {27 return NotFound(new28 {29 error = "not_found",30 message = $"Parcel with ID '{id}' was not found"31 });32 }3334 // Map entity to a patchable model35 var patchModel = new ParcelPatchModel36 {37 Status = parcel.Status,38 ServiceType = parcel.ServiceType,39 Description = parcel.Description,40 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate41 };4243 // Apply the patch operations44 patchDoc.ApplyTo(patchModel, ModelState);4546 if (!ModelState.IsValid)47 {48 return UnprocessableEntity(new49 {50 error = "invalid_patch",51 message = "The patch document contains invalid operations",52 errors = ModelState.Values53 .SelectMany(v => v.Errors)54 .Select(e => e.ErrorMessage)55 });56 }5758 // Validate status transition if status changed59 if (patchModel.Status != parcel.Status)60 {61 var result = await _statusService.TransitionStatusAsync(62 id, patchModel.Status);6364 return ToActionResult(result);65 }6667 // Apply non-status changes68 parcel.ServiceType = patchModel.ServiceType;69 parcel.Description = patchModel.Description;70 parcel.EstimatedDeliveryDate = patchModel.EstimatedDeliveryDate;71 parcel.UpdatedAt = DateTimeOffset.UtcNow;7273 await _db.SaveChangesAsync();7475 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:
csharp1public class ParcelPatchModel2{3 [JsonConverter(typeof(StringEnumConverter))]4 public ParcelStatus Status { get; set; }56 [JsonConverter(typeof(StringEnumConverter))]7 public ServiceType ServiceType { get; set; }89 [MaxLength(500)]10 public string? Description { get; set; }1112 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, orCreatedAt - 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
http1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa62Content-Type: application/json-patch+json34[5 { "op": "replace", "path": "/status", "value": "PickedUp" }6]
http1HTTP/1.1 200 OK2Content-Type: application/json34{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
http1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa62Content-Type: application/json-patch+json34[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
http1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa62Content-Type: application/json-patch+json34[5 { "op": "replace", "path": "/status", "value": "Delivered" }6]
http1HTTP/1.1 422 Unprocessable Entity2Content-Type: application/json34{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
http1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa62Content-Type: application/json-patch+json34[5 { "op": "replace", "path": "/status", "value": "InTransit" }6]
http1HTTP/1.1 422 Unprocessable Entity2Content-Type: application/json34{5 "error": "terminal_state",6 "message": "Parcel is in terminal state 'Delivered' and cannot be modified",7 "currentStatus": "Delivered"8}
Invalid Patch Operation
http1PATCH /api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa62Content-Type: application/json-patch+json34[5 { "op": "replace", "path": "/trackingNumber", "value": "HACKED" }6]
http1HTTP/1.1 422 Unprocessable Entity2Content-Type: application/json34{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:
csharp1public class ParcelService2{3 private readonly ParcelRepository _repository;45 public async Task<Parcel?> GetWritableParcelAsync(Guid parcelId)6 {7 var parcel = await _repository.GetByIdAsync(parcelId);89 if (parcel is null)10 {11 return null;12 }1314 if (ParcelStatusRules.IsTerminal(parcel.Status))15 {16 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);17 }1819 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:
csharp1public class ParcelInTerminalStateException : System.Exception2{3 public Guid ParcelId { get; }4 public ParcelStatus Status { get; }56 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:
csharp1public class TerminalStateExceptionHandler : IExceptionHandler2{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 }1213 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;14 await httpContext.Response.WriteAsJsonAsync(new15 {16 error = "terminal_state",17 message = terminalEx.Message,18 parcelId = terminalEx.ParcelId,19 currentStatus = terminalEx.Status.ToString()20 }, cancellationToken);2122 return true;23 }24}
Register it in Program.cs:
csharp1builder.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:
csharp1[HttpPut("{id:guid}/address")]2public async Task<IActionResult> UpdateAddress(3 Guid id, [FromBody] UpdateAddressRequest request)4{5 // GetWritableParcelAsync throws if terminal6 var parcel = await _parcelService.GetWritableParcelAsync(id);78 if (parcel is null)9 {10 return NotFound();11 }1213 parcel.RecipientAddress = request.Address;14 parcel.UpdatedAt = DateTimeOffset.UtcNow;15 await _repository.UpdateAsync(parcel);1617 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:
csharp1public async Task<StatusTransitionResult> TransitionStatusAsync(2 Guid parcelId, ParcelStatus newStatus)3{4 var parcel = await _repository.GetByIdAsync(parcelId);56 if (parcel is null)7 return StatusTransitionResult.NotFound(parcelId);89 if (ParcelStatusRules.IsTerminal(parcel.Status))10 return StatusTransitionResult.TerminalState(parcel.Status);1112 if (!ParcelStatusRules.CanTransition(parcel.Status, newStatus))13 return StatusTransitionResult.InvalidTransition(14 parcel.Status, newStatus,15 ParcelStatusRules.GetAllowedTransitions(parcel.Status));1617 var previousStatus = parcel.Status;18 parcel.Status = newStatus;19 parcel.UpdatedAt = DateTimeOffset.UtcNow;20 await _repository.UpdateAsync(parcel);2122 // Record the transition as a tracking event23 var trackingEvent = new TrackingEvent24 {25 ParcelId = parcelId,26 Status = newStatus,27 Description = $"Status changed from {previousStatus} to {newStatus}",28 Timestamp = DateTimeOffset.UtcNow29 };30 await _trackingEventRepository.AddAsync(trackingEvent);3132 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:
csharp1private 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");89 return await _client.SendAsync(10 new HttpRequestMessage(HttpMethod.Patch, $"/api/parcels/{id}")11 {12 Content = content13 });14}1516[Fact]17public async Task Patch_ValidStatusTransition_Returns200()18{19 // Arrange: create a parcel in LabelCreated state20 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);2122 // Act23 var response = await PatchParcel(parcelId,24 new { op = "replace", path = "/status", value = "PickedUp" });2526 // Assert27 Assert.Equal(HttpStatusCode.OK, response.StatusCode);28 var body = await response.Content29 .ReadFromJsonAsync<ParcelResponse>();30 Assert.Equal("PickedUp", body!.Status);31}3233[Fact]34public async Task Patch_InvalidTransition_Returns422()35{36 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);3738 var response = await PatchParcel(parcelId,39 new { op = "replace", path = "/status", value = "Delivered" });4041 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);42}4344[Fact]45public async Task Patch_TerminalState_Returns422()46{47 var parcelId = await CreateTestParcel(ParcelStatus.Delivered);4849 var response = await PatchParcel(parcelId,50 new { op = "replace", path = "/status", value = "InTransit" });5152 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);53}5455[Fact]56public async Task Patch_ReadOnlyField_Returns422()57{58 var parcelId = await CreateTestParcel(ParcelStatus.LabelCreated);5960 var response = await PatchParcel(parcelId,61 new { op = "replace", path = "/trackingNumber", value = "HACKED" });6263 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);64}6566[Fact]67public async Task UpdateAddress_TerminalState_Returns422()68{69 var parcelId = await CreateTestParcel(ParcelStatus.Returned);7071 var response = await _client.PutAsJsonAsync(72 $"/api/parcels/{parcelId}/address",73 new { address = "123 New St" });7475 Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);76}
Swagger Documentation
The [ProducesResponseType] attributes on the controller action ensure Swagger documents all possible responses:
csharp1[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:
json1{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.JsonPatchandMicrosoft.AspNetCore.Mvc.NewtonsoftJsonfor JSON Patch support - Apply patches to an intermediate model (not the entity) to control which fields are modifiable
- The
testoperation 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