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.

Extending the Service Layer with UpdateStatusAsync

First, extend the IParcelService interface to handle PATCH operations:

csharp
1public interface IParcelService
2{
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:

csharp
1public class ParcelService : IParcelService
2{
3 private readonly ParcelTrackingDbContext _db;
4 private readonly IMapper _mapper;
5
6 public ParcelService(ParcelTrackingDbContext db, IMapper mapper)
7 {
8 _db = db;
9 _mapper = mapper;
10 }
11
12 public async Task<ParcelDto?> UpdateStatusAsync(
13 Guid id, JsonPatchDocument<Parcel> patchDoc)
14 {
15 var parcel = await _db.Parcels.FindAsync(id);
16
17 if (parcel is null)
18 {
19 return null;
20 }
21
22 // Check terminal state before any modifications
23 if (ParcelStatusRules.IsTerminal(parcel.Status))
24 {
25 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);
26 }
27
28 // Capture the original status before applying patch
29 var originalStatus = parcel.Status;
30
31 // Apply the patch document
32 patchDoc.ApplyTo(parcel);
33
34 // If status changed, validate the transition
35 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 }
46
47 parcel.UpdatedAt = DateTimeOffset.UtcNow;
48 await _db.SaveChangesAsync();
49
50 return _mapper.Map<ParcelDto>(parcel);
51 }
52}

The service enforces two critical business rules:

  1. Terminal state protection: Parcels in terminal states (Delivered, Returned) cannot be modified
  2. 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:

csharp
1[ApiController]
2[Route("api/[controller]")]
3public class ParcelsController : ControllerBase
4{
5 private readonly IParcelService _parcelService;
6
7 public ParcelsController(IParcelService parcelService)
8 {
9 _parcelService = parcelService;
10 }
11
12 [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);
20
21 if (result is null)
22 {
23 return NotFound(new
24 {
25 error = "not_found",
26 message = $"Parcel with ID '{id}' was not found"
27 });
28 }
29
30 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:

csharp
1public class InvalidStatusTransitionException : Exception
2{
3 public ParcelStatus CurrentStatus { get; }
4 public ParcelStatus RequestedStatus { get; }
5 public ParcelStatus[] AllowedStatuses { get; }
6
7 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}
18
19public class ParcelInTerminalStateException : Exception
20{
21 public Guid ParcelId { get; }
22 public ParcelStatus Status { get; }
23
24 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

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.

Global Exception Handling

Exception handlers convert service-layer exceptions into appropriate HTTP responses. This centralizes error handling logic:

csharp
1public class InvalidStatusTransitionExceptionHandler : IExceptionHandler
2{
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 }
12
13 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
14 await httpContext.Response.WriteAsJsonAsync(new
15 {
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);
22
23 return true;
24 }
25}
26
27public class TerminalStateExceptionHandler : IExceptionHandler
28{
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 }
38
39 httpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
40 await httpContext.Response.WriteAsJsonAsync(new
41 {
42 error = "terminal_state",
43 message = ex.Message,
44 parcelId = ex.ParcelId,
45 currentStatus = ex.Status.ToString()
46 }, cancellationToken);
47
48 return true;
49 }
50}

Register both handlers in Program.cs:

csharp
1builder.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:

csharp
1// IParcelService
2public interface IParcelService
3{
4 Task<ParcelDto?> UpdateAddressAsync(Guid id, string newAddress);
5}
6
7// ParcelService implementation
8public async Task<ParcelDto?> UpdateAddressAsync(Guid id, string newAddress)
9{
10 var parcel = await _db.Parcels.FindAsync(id);
11
12 if (parcel is null)
13 {
14 return null;
15 }
16
17 // Business rule: terminal states cannot be modified
18 if (ParcelStatusRules.IsTerminal(parcel.Status))
19 {
20 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);
21 }
22
23 parcel.RecipientAddress = newAddress;
24 parcel.UpdatedAt = DateTimeOffset.UtcNow;
25 await _db.SaveChangesAsync();
26
27 return _mapper.Map<ParcelDto>(parcel);
28}
29
30// Controller
31[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);
36
37 if (result is null)
38 {
39 return NotFound();
40 }
41
42 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:

csharp
1public async Task<ParcelDto?> UpdateStatusAsync(
2 Guid id, JsonPatchDocument<Parcel> patchDoc)
3{
4 var parcel = await _db.Parcels.FindAsync(id);
5
6 if (parcel is null)
7 {
8 return null;
9 }
10
11 if (ParcelStatusRules.IsTerminal(parcel.Status))
12 {
13 throw new ParcelInTerminalStateException(parcel.Id, parcel.Status);
14 }
15
16 var originalStatus = parcel.Status;
17 patchDoc.ApplyTo(parcel);
18
19 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 }
29
30 // Record the status change as a tracking event
31 var trackingEvent = new TrackingEvent
32 {
33 ParcelId = id,
34 Status = parcel.Status,
35 Description = $"Status changed from {originalStatus} to {parcel.Status}",
36 Timestamp = DateTimeOffset.UtcNow
37 };
38 _db.TrackingEvents.Add(trackingEvent);
39 }
40
41 parcel.UpdatedAt = DateTimeOffset.UtcNow;
42 await _db.SaveChangesAsync();
43
44 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:

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(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:

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
  • Implement UpdateStatusAsync in 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