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.
Extending the Service Layer with UpdateStatusAsync
First, extend the IParcelService interface to handle PATCH operations:
csharp1public interface IParcelService2{3 Task<ParcelDto?> GetByIdAsync(Guid id);4 Task<ParcelDto> CreateAsync(CreateParcelDto dto);5 Task<ParcelDto?> UpdateStatusAsync(Guid id, JsonPatchDocument<Parcel> patchDoc);6}
The UpdateStatusAsync method takes a JsonPatchDocument<Parcel> and returns the updated DTO if successful, or null if the parcel is not found.
Implementing UpdateStatusAsync in ParcelService
Here's the service implementation that validates business rules before applying the patch:
csharp1public class ParcelService : IParcelService2{3 private readonly ParcelTrackingDbContext _db;4 private readonly IMapper _mapper;56 public ParcelService(ParcelTrackingDbContext db, IMapper mapper)7 {8 _db = db;9 _mapper = mapper;10 }1112 public async Task<ParcelDto?> UpdateStatusAsync(13 Guid id, JsonPatchDocument<Parcel> patchDoc)14 {15 var parcel = await _db.Parcels.FindAsync(id);1617 if (parcel is null)18 {19 return null;20 }2122 // Check terminal state before any modifications23 if (ParcelStatusRules.IsTerminal(parcel.Status))24 {25 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);26 }2728 // Capture the original status before applying patch29 var originalStatus = parcel.Status;3031 // Apply the patch document32 patchDoc.ApplyTo(parcel);3334 // If status changed, validate the transition35 if (parcel.Status != originalStatus)36 {37 if (!ParcelStatusRules.CanTransition(originalStatus, parcel.Status))38 {39 var allowedStatuses = ParcelStatusRules.GetAllowedTransitions(originalStatus);40 throw new InvalidStatusTransitionException(41 originalStatus,42 parcel.Status,43 allowedStatuses);44 }45 }4647 parcel.UpdatedAt = DateTimeOffset.UtcNow;48 await _db.SaveChangesAsync();4950 return _mapper.Map<ParcelDto>(parcel);51 }52}
The service enforces two critical business rules:
- Terminal state protection: Parcels in terminal states (Delivered, Returned) cannot be modified
- Valid state transitions: Status changes must follow the state machine (e.g., cannot go from LabelCreated directly to Delivered)
Building the PATCH Endpoint
The controller becomes thin, delegating all business logic to the service:
csharp1[ApiController]2[Route("api/[controller]")]3public class ParcelsController : ControllerBase4{5 private readonly IParcelService _parcelService;67 public ParcelsController(IParcelService parcelService)8 {9 _parcelService = parcelService;10 }1112 [HttpPatch("{id:guid}")]13 [ProducesResponseType(typeof(ParcelDto), StatusCodes.Status200OK)]14 [ProducesResponseType(StatusCodes.Status404NotFound)]15 [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]16 public async Task<IActionResult> Patch(17 Guid id, [FromBody] JsonPatchDocument<Parcel> patchDoc)18 {19 var result = await _parcelService.UpdateStatusAsync(id, patchDoc);2021 if (result is null)22 {23 return NotFound(new24 {25 error = "not_found",26 message = $"Parcel with ID '{id}' was not found"27 });28 }2930 return Ok(result);31 }32}
The controller's only responsibility is routing and HTTP concerns. All validation, business rules, and data access happen in the service layer.
Business Rule Exceptions
The service throws specific exceptions when business rules are violated:
csharp1public class InvalidStatusTransitionException : Exception2{3 public ParcelStatus CurrentStatus { get; }4 public ParcelStatus RequestedStatus { get; }5 public ParcelStatus[] AllowedStatuses { get; }67 public InvalidStatusTransitionException(8 ParcelStatus current,9 ParcelStatus requested,10 ParcelStatus[] allowed)11 : base($"Cannot transition from '{current}' to '{requested}'")12 {13 CurrentStatus = current;14 RequestedStatus = requested;15 AllowedStatuses = allowed;16 }17}1819public class ParcelInTerminalStateException : Exception20{21 public Guid ParcelId { get; }22 public ParcelStatus Status { get; }2324 public ParcelInTerminalStateException(Guid parcelId, ParcelStatus status)25 : base($"Parcel {parcelId} is in terminal state '{status}' and cannot be modified")26 {27 ParcelId = parcelId;28 Status = status;29 }30}
These exceptions are caught by global exception handlers (covered in the next lesson) and converted to appropriate HTTP responses.
Why Business Rules Live in the Service Layer
Placing validation in the service layer rather than the controller provides:
- Reusability: The same rules apply whether the update comes from a REST API, gRPC service, or background job
- Testability: Business logic can be unit tested without spinning up the HTTP pipeline
- Separation of concerns: Controllers handle HTTP routing, services handle domain logic
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.
Global Exception Handling
Exception handlers convert service-layer exceptions into appropriate HTTP responses. This centralizes error handling logic:
csharp1public class InvalidStatusTransitionExceptionHandler : IExceptionHandler2{3 public async ValueTask<bool> TryHandleAsync(4 HttpContext httpContext,5 Exception exception,6 CancellationToken cancellationToken)7 {8 if (exception is not InvalidStatusTransitionException ex)9 {10 return false;11 }1213 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;14 await httpContext.Response.WriteAsJsonAsync(new15 {16 error = "invalid_transition",17 message = ex.Message,18 currentStatus = ex.CurrentStatus.ToString(),19 requestedStatus = ex.RequestedStatus.ToString(),20 allowedStatuses = ex.AllowedStatuses.Select(s => s.ToString())21 }, cancellationToken);2223 return true;24 }25}2627public class TerminalStateExceptionHandler : IExceptionHandler28{29 public async ValueTask<bool> TryHandleAsync(30 HttpContext httpContext,31 Exception exception,32 CancellationToken cancellationToken)33 {34 if (exception is not ParcelInTerminalStateException ex)35 {36 return false;37 }3839 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;40 await httpContext.Response.WriteAsJsonAsync(new41 {42 error = "terminal_state",43 message = ex.Message,44 parcelId = ex.ParcelId,45 currentStatus = ex.Status.ToString()46 }, cancellationToken);4748 return true;49 }50}
Register both handlers in Program.cs:
csharp1builder.Services.AddExceptionHandler<InvalidStatusTransitionExceptionHandler>();2builder.Services.AddExceptionHandler<TerminalStateExceptionHandler>();
Now controllers don't need try-catch blocks. Exceptions thrown by the service layer are automatically converted to consistent HTTP responses.
Applying Business Rules Consistently
The service layer ensures business rules apply to all operations. For example, updating a parcel's address:
csharp1// IParcelService2public interface IParcelService3{4 Task<ParcelDto?> UpdateAddressAsync(Guid id, string newAddress);5}67// ParcelService implementation8public async Task<ParcelDto?> UpdateAddressAsync(Guid id, string newAddress)9{10 var parcel = await _db.Parcels.FindAsync(id);1112 if (parcel is null)13 {14 return null;15 }1617 // Business rule: terminal states cannot be modified18 if (ParcelStatusRules.IsTerminal(parcel.Status))19 {20 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);21 }2223 parcel.RecipientAddress = newAddress;24 parcel.UpdatedAt = DateTimeOffset.UtcNow;25 await _db.SaveChangesAsync();2627 return _mapper.Map<ParcelDto>(parcel);28}2930// Controller31[HttpPut("{id:guid}/address")]32public async Task<IActionResult> UpdateAddress(33 Guid id, [FromBody] UpdateAddressRequest request)34{35 var result = await _parcelService.UpdateAddressAsync(id, request.Address);3637 if (result is null)38 {39 return NotFound();40 }4142 return Ok(result);43}
The terminal state check happens in the service, not the controller. The exception handler converts it to a 422 response automatically.
Adding a Tracking Event on Status Change
The service layer can also handle audit logging when status changes occur:
csharp1public async Task<ParcelDto?> UpdateStatusAsync(2 Guid id, JsonPatchDocument<Parcel> patchDoc)3{4 var parcel = await _db.Parcels.FindAsync(id);56 if (parcel is null)7 {8 return null;9 }1011 if (ParcelStatusRules.IsTerminal(parcel.Status))12 {13 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);14 }1516 var originalStatus = parcel.Status;17 patchDoc.ApplyTo(parcel);1819 if (parcel.Status != originalStatus)20 {21 if (!ParcelStatusRules.CanTransition(originalStatus, parcel.Status))22 {23 var allowedStatuses = ParcelStatusRules.GetAllowedTransitions(originalStatus);24 throw new InvalidStatusTransitionException(25 originalStatus,26 parcel.Status,27 allowedStatuses);28 }2930 // Record the status change as a tracking event31 var trackingEvent = new TrackingEvent32 {33 ParcelId = id,34 Status = parcel.Status,35 Description = $"Status changed from {originalStatus} to {parcel.Status}",36 Timestamp = DateTimeOffset.UtcNow37 };38 _db.TrackingEvents.Add(trackingEvent);39 }4041 parcel.UpdatedAt = DateTimeOffset.UtcNow;42 await _db.SaveChangesAsync();4344 return _mapper.Map<ParcelDto>(parcel);45}
This creates a complete audit trail of every status change, managed entirely within the service layer.
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(ParcelDto), StatusCodes.Status200OK)]4[ProducesResponseType(StatusCodes.Status404NotFound)]5[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]6public async Task<IActionResult> Patch(7 Guid id, [FromBody] JsonPatchDocument<Parcel> 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 - Implement
UpdateStatusAsyncin the service layer to handle patch operations and enforce business rules - Business rules (terminal state protection, valid transitions) are enforced in the service, not the controller
- Controllers delegate to service methods and only handle HTTP concerns (routing, status codes)
- Throw domain-specific exceptions (
InvalidStatusTransitionException,ParcelInTerminalStateException) from the service - Use global exception handlers to convert service exceptions into consistent HTTP responses
- Record tracking events in the service layer when status changes succeed for audit purposes
- Use
[Consumes("application/json-patch+json")]and[ProducesResponseType]for Swagger documentation - The service layer ensures business rules apply consistently across all endpoints