PATCH Handling Across CDA Endpoints
Summary
This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL
identify the target resource using path parameters and include only the fields to be modified in the request body.
Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using
Jackson’s ObjectMapper.readerForUpdating().
Opinions
Opinion 1
@brysonspilman
Summary
PATCH requests should represent both partial and total updates. Resource identifiers belong in the request path,
while the request body contains only the properties to be modified. To preserve the distinction between omitted
properties and properties explicitly set to null, PATCH operations will retrieve the existing resource and
apply the incoming JSON onto that object using Jackson’s update capabilities.
That mechanism covers scalar fields well: Jackson’s tree merge (ObjectMapper.readerForUpdating())
naturally leaves an omitted scalar field unchanged and overwrites one that’s present, because it
can compare the existing value and the incoming value directly. A collection-typed field (the
rows of a time series, for example) doesn’t have that luxury on its own – Jackson’s merge has no
built-in way to match an element of the existing array against an element of the incoming array
by identity, so its only two well-defined behaviors for an array field are to replace it wholesale
or to append the incoming elements onto the existing ones. The second of those isn’t safe to
expose as its own strategy: a plain append can leave two items sharing the same identity sitting
side by side in the collection, which is exactly what these DTOs’ identity fields (see MERGE
below) exist to rule out. Rather than an endpoint silently committing to either native behavior,
PATCH endpoints whose body may include a collection field SHALL expose a
collection-merge-strategy query parameter so the caller picks between wholesale replacement
and an identity-aware merge – see OVERWRITE and MERGE below.
For endpoints scoped by a time window (begin / end, as the time-series endpoints are), that
window bounds the entire operation: it’s what determines which existing rows are retrieved prior
to the merge, and consequently the only rows that can ever be read or affected by the PATCH at
all. Data outside the window is never touched, regardless of merge strategy.
Key points
Topic |
Decision |
Justification |
|---|---|---|
Resource identification |
Resource identifiers SHALL be provided as path parameters |
Follows REST conventions and avoids duplication of resource identity between the URI and request body. |
Request body |
Include only the fields to be modified |
PATCH semantics represent partial updates rather than full resource replacement. |
Omitted fields |
Omitted properties SHALL remain unchanged |
Clients should only send the fields they intend to modify. |
Explicit null values |
Properties explicitly provided with a value of |
Distinguishes “remove this value” from “leave this value unchanged.” |
DTO reuse |
Existing DTOs SHALL be reused for PATCH operations |
Avoids introducing PATCH-specific DTOs or wrapper types across the API. |
Update implementation |
Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using |
Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. |
Validation |
Validate the resulting object after the update has been applied |
Validation should be performed against the final resource state. |
PUT semantics |
PUT remains the mechanism for complete resource replacement |
Preserves the standard semantic distinction between PUT and PATCH. |
Implementation tradeoff |
PATCH operations require retrieval of the existing resource prior to applying updates |
The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit |
Backwards compatibility |
Existing DTOs and serialization formats remain unchanged |
Minimizes implementation effort and avoids widespread API changes. |
Collection merge control |
PATCH endpoints with a collection field SHALL accept a |
Names the operation precisely rather than reusing a bare “replace” that could be misread as describing HTTP semantics rather than this specific per-row behavior. |
PATCH Handling Across CDA Endpoints
Summary
This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson’s ObjectMapper.readerForUpdating().
Opinions
Opinion 1
@brysonspilman
Summary
PATCH requests should represent partial updates only. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to null, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson’s update capabilities.
Key points
Topic |
Decision |
Justification |
|---|---|---|
Resource identification |
Resource identifiers SHALL be provided as path parameters |
Follows REST conventions and avoids duplication of resource identity between the URI and request body. |
Request body |
Include only the fields to be modified |
PATCH semantics represent partial updates rather than full resource replacement. |
Omitted fields |
Omitted properties SHALL remain unchanged |
Clients should only send the fields they intend to modify. |
Explicit null values |
Properties explicitly provided with a value of |
Distinguishes “remove this value” from “leave this value unchanged.” |
DTO reuse |
Existing DTOs SHALL be reused for PATCH operations |
Avoids introducing PATCH-specific DTOs or wrapper types across the API. |
Update implementation |
Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using |
Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. |
Validation |
Validate the resulting object after the update has been applied |
Validation should be performed against the final resource state. |
PUT semantics |
PUT remains the mechanism for complete resource replacement |
Preserves the standard semantic distinction between PUT and PATCH. |
Implementation tradeoff |
PATCH operations require retrieval of the existing resource prior to applying updates |
The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit |
Backwards compatibility |
Existing DTOs and serialization formats remain unchanged |
Minimizes implementation effort and avoids widespread API changes. |
Example
Example endpoint:
PATCH /entity/{entity-id}
Request:
PATCH /entity/MyEntity
Request body:
{
"long-name": "Updated Entity Long Name",
"parent-entity-id": "NewParent"
}
Implementation flow:
Retrieve the existing
Entityidentified byentity-id.Populate the existing DTO.
Apply the incoming JSON using
ObjectMapper.readerForUpdating(existingDto).Validate the resulting object.
Persist the updated resource.
For example, given the existing resource:
{
"id": {
"office-id": "SWT",
"name": "MyEntity"
},
"parent-entity-id": "ParentA",
"category-id": "Reservoir",
"long-name": "Original Long Name"
}
and the PATCH request:
{
"category-id": "Dam",
"parent-entity-id": null
}
the resulting object after applying the PATCH becomes:
{
"id": {
"office-id": "SWT",
"name": "MyEntity"
},
"parent-entity-id": null,
"category-id": "Dam",
"long-name": "Original Long Name"
}
Only the properties present in the request body are modified. The id remains unchanged because it is derived from the request path, and long-name remains unchanged because it was omitted from the PATCH payload.
Existing endpoints that support PATCH
Endpoint Path |
Controller |
Support Level |
Notes |
|---|---|---|---|
/entity/{entity-id} |
EntityController |
Full-patch |
Reuses existing DTO and updates fields. |
/locations/{location-id} |
LocationController |
Full-patch |
Supports partial updates and renaming if the name in the body differs. |
/timeseries/{timeseries} |
TimeSeriesController |
Full-patch |
Used to store/update time series data. |
/levels/{level-id} |
LevelsController |
Full-patch |
Supports partial updates and renaming. |
/clobs/{clob-id} |
ClobController |
Full-patch |
Supports updating clob value/description; allows ignore-nulls. |
/location/{location-id}/vertical-datum |
VerticalDatumController |
Full-patch |
Updates vertical datum information for a location. |
/ratings/{rating-id} |
RatingController |
Full-patch |
Updates/stores RatingSet data. |
/timeseries/text/{name} |
TextTimeSeriesController |
Full-patch |
Updates text time series values. |
/timeseries/binary/{name} |
BinaryTimeSeriesController |
Full-patch |
Updates binary time series values. |
/forecast-instance/{name} |
ForecastInstanceController |
Full-patch |
Updates notes, max age, and files for a forecast instance. |
/forecast-spec/{name} |
ForecastSpecController |
Full-patch |
Updates forecast specification values. |
/properties/{name} |
PropertyController |
Full-patch |
Updates property values. |
/stream-locations/{name} |
StreamLocationController |
Full-patch |
Updates stream location attributes. |
/timeseries/category/{category-id} |
TimeSeriesCategoryController |
Full-patch |
Supports renaming and updating descriptions. |
/lookup-types/{name} |
LookupTypeController |
Full-patch |
Updates lookup type display values and tooltips. |
/basins/{name} |
BasinController |
Rename-only |
Primarily used for renaming the basin via the name query parameter. |
/projects/{name} |
ProjectController |
Rename-only |
Renames a project using the name query parameter. |
/projects/embankments/{name} |
EmbankmentController |
Rename-only |
Renames an embankment. |
/projects/turbines/{name} |
TurbineController |
Rename-only |
Renames a turbine. |
/projects/locks/{name} |
LockController |
Rename-only |
Renames a lock. |
/projects/outlets/{name} |
OutletController |
Rename-only |
Renames an outlet. |
/streams/{name} |
StreamController |
Rename-only |
Renames a stream. |
/stream-reaches/{name} |
StreamReachController |
Rename-only |
Renames a stream reach. |
/specified-levels/{specified-level-id} |
SpecifiedLevelController |
Rename-only |
Renames a specified level ID. |
/timeseries/group/{group-id} |
TimeSeriesGroupController |
Rename/ Specific fields |
Supports renaming and assigning/unassigning time series. |
/location/group/{group-id} |
LocationGroupController |
Rename / Specific fields |
Supports renaming and assigning/unassigning locations. |
/timeseries/identifier-descriptor/{name} |
TimeSeriesIdentifierDescriptorController |
Rename / Specific fields |
Supports renaming and updating snap tolerances. |
/projects/{office}/{project-id}/water-user/{water-user} |
WaterUserUpdateController |
Rename-only |
Renames a water user. |
/projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} |
WaterContractUpdateController |
Rename-only |
Renames a water contract. |
Decision Status
(Status: accepted)
References
Related Pattern: HTTP PATCH
Jackson ObjectMapper.readerForUpdating()
RFC 5789 - PATCH Method for HTTP
RFC 7396 -
Operation |
Description |
Usage |
|---|---|---|
JSON Merge Patch action becomes exactly the set of items named in the request body. Any existing item that falls within the request's time window but isn’t named in the body is removed. |
Matches "replace" as most callers mean it for a bounded window: the window's collection now looks exactly like what was sent, not like what was sent plus leftovers. |
|
MERGE |
Items named in the request body are matched against existing items by that collection’s identity field(s), then updated in place – preserving that one item’s own fields the body omits, the same way a top-level PATCH preserves an omitted scalar field. An unmatched identity is added as new. Every other existing item, in or out of the request’s time window, is left exactly as it was. |
The precise, surgical option: change just the named item(s) without disturbing anything else in the collection, including items in-window that OVERWRITE would otherwise remove. |
MERGE’s identity |
An item’s identity is whichever field(s) of its class are annotated |
A single field isn’t always enough to say two items are “the same” one – text-timeseries
rows are the concrete case: |
Time window scope |
The |
The window is what the existing resource retrieval is already scoped to; keeping the storage step’s reach to that same scope (and no further) keeps the window’s meaning consistent between GET and PATCH. |
Superseding replace-all (query parameter) |
The old |
The old |
Storage implementation |
Storage is uniform across both strategies: the controller deletes everything in the
|
The merge already computes the correct final row set per strategy – OVERWRITE’s named
items only, MERGE’s matched-and-updated items plus every untouched existing item carried
through, plus any unmatched incoming item added as new (possibly sharing a date-time with
an existing item it didn’t match, since its identity – date-time and data-entry-date
together – differs) – so uniformly clearing and restoring the window reaches the right
end state regardless of strategy. The store call can’t do this alone: it only ever touches
the date-times it’s given, never removing one it isn’t, which is why the delete is still
required. Running both in one transaction also means a failure partway through can’t leave
the window deleted but not repopulated, the way two independent calls could. Because the
window is fully cleared first, a new row that happens to share a date-time with something
already there is stored into an empty slot rather than colliding with anything, so
|
Absent or empty collection |
A PATCH body that omits the collection field entirely, or names it with an empty array, still goes through the same delete-and-restore – but the merge carries the existing rows through unchanged, so the window’s observable content afterward is identical to what it was before. |
The merge already resolves this without a separate check: an absent or empty collection field never changes what the merged DTO says the window should contain, so the same uniform storage step reaches the correct (unchanged) result. The tradeoff is that every row in the window is still deleted and re-stored even when nothing about the collection was named in the body, rather than being left alone entirely. |
Example
Example endpoint:
PATCH /entity/{entity-id}
Request:
PATCH /entity/MyEntity
Request body:
{
"long-name": "Updated Entity Long Name",
"parent-entity-id": "NewParent"
}
Implementation flow:
Retrieve the existing
Entityidentified byentity-id.Populate the existing DTO.
Apply the incoming JSON using
ObjectMapper.readerForUpdating(existingDto).Validate the resulting object.
Persist the updated resource.
For example, given the existing resource:
{
"id": {
"office-id": "SWT",
"name": "MyEntity"
},
"parent-entity-id": "ParentA",
"category-id": "Reservoir",
"long-name": "Original Long Name"
}
and the PATCH request:
{
"category-id": "Dam",
"parent-entity-id": null
}
the resulting object after applying the PATCH becomes:
{
"id": {
"office-id": "SWT",
"name": "MyEntity"
},
"parent-entity-id": null,
"category-id": "Dam",
"long-name": "Original Long Name"
}
Only the properties present in the request body are modified. The id remains unchanged because it is derived from the request path, and long-name remains unchanged because it was omitted from the PATCH payload.
Given a text time series with five existing values at hourly date-times 01:00-05:00,
each with its own data-entry-date assigned by the database (say the 03:00 row’s is
...T00:00:05Z), and the PATCH request:
PATCH /timeseries/text/SPK/MyTs?begin=...T01:00:00Z&end=...T05:00:00Z&collection-merge-strategy=merge
{
"regular-text-values": [
{"date-time": "...T03:00:00Z", "data-entry-date": "...T00:00:05Z", "text-value": "updated"}
]
}
matches the existing 03:00 row by its composite (date-time, data-entry-date) identity
– both fields marked @Identifier on the row’s class – and updates just that row’s
text-value, leaving the other four rows untouched. Omitting data-entry-date from the body
instead of supplying the row’s actual value:
{
"regular-text-values": [
{"date-time": "...T03:00:00Z", "text-value": "updated"}
]
}
does not match the existing 03:00 row at all – a null or absent identity field never
matches, even against an existing row whose own value happens to be null – so MERGE adds this as
a sixth, distinct row alongside the original 03:00 value rather than updating it. The
identical first request with collection-merge-strategy=overwrite instead removes the other
four rows entirely, since OVERWRITE means the window’s collection becomes exactly what the body
named.
The same request with regular-text-values omitted from the body entirely, or sent as
"regular-text-values": [], changes nothing regardless of collection-merge-strategy – all
five original values remain exactly as they were.
Existing endpoints that support PATCH
Endpoint Path |
Controller |
Support Level |
Notes |
|---|---|---|---|
/entity/{entity-id} |
EntityController |
Full-patch |
Reuses existing DTO and updates fields. |
/locations/{location-id} |
LocationController |
Full-patch |
Supports partial updates and renaming if the name in the body differs. |
/timeseries/{timeseries} |
TimeSeriesController |
Full-patch |
Used to store/update time series data. |
/levels/{level-id} |
LevelsController |
Full-patch |
Supports partial updates and renaming. |
/clobs/{clob-id} |
ClobController |
Full-patch |
Supports updating clob value/description; allows ignore-nulls. |
/location/{location-id}/vertical-datum |
VerticalDatumController |
Full-patch |
Updates vertical datum information for a location. |
/ratings/{rating-id} |
RatingController |
Full-patch |
Updates/stores RatingSet data. |
/timeseries/text/{name} |
TextTimeSeriesController |
Full-patch |
Updates text time series values; supports |
/timeseries/binary/{name} |
BinaryTimeSeriesController |
Full-patch |
Updates binary time series values. |
/forecast-instance/{name} |
ForecastInstanceController |
Full-patch |
Updates notes, max age, and files for a forecast instance. |
/forecast-spec/{name} |
ForecastSpecController |
Full-patch |
Updates forecast specification values. |
/properties/{name} |
PropertyController |
Full-patch |
Updates property values. |
/stream-locations/{name} |
StreamLocationController |
Full-patch |
Updates stream location attributes. |
/timeseries/category/{category-id} |
TimeSeriesCategoryController |
Full-patch |
Supports renaming and updating descriptions. |
/lookup-types/{name} |
LookupTypeController |
Full-patch |
Updates lookup type display values and tooltips. |
/basins/{name} |
BasinController |
Rename-only |
Primarily used for renaming the basin via the name query parameter. |
/projects/{name} |
ProjectController |
Rename-only |
Renames a project using the name query parameter. |
/projects/embankments/{name} |
EmbankmentController |
Rename-only |
Renames an embankment. |
/projects/turbines/{name} |
TurbineController |
Rename-only |
Renames a turbine. |
/projects/locks/{name} |
LockController |
Rename-only |
Renames a lock. |
/projects/outlets/{name} |
OutletController |
Rename-only |
Renames an outlet. |
/streams/{name} |
StreamController |
Rename-only |
Renames a stream. |
/stream-reaches/{name} |
StreamReachController |
Rename-only |
Renames a stream reach. |
/specified-levels/{specified-level-id} |
SpecifiedLevelController |
Rename-only |
Renames a specified level ID. |
/timeseries/group/{group-id} |
TimeSeriesGroupController |
Rename/ Specific fields |
Supports renaming and assigning/unassigning time series. |
/location/group/{group-id} |
LocationGroupController |
Rename / Specific fields |
Supports renaming and assigning/unassigning locations. |
/timeseries/identifier-descriptor/{name} |
TimeSeriesIdentifierDescriptorController |
Rename / Specific fields |
Supports renaming and updating snap tolerances. |
/projects/{office}/{project-id}/water-user/{water-user} |
WaterUserUpdateController |
Rename-only |
Renames a water user. |
/projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} |
WaterContractUpdateController |
Rename-only |
Renames a water contract. |
Decision Status
(Status: accepted)
References
Related Pattern: HTTP PATCH
Jackson ObjectMapper.readerForUpdating()
RFC 5789 - PATCH Method for HTTP
RFC 7396 - JSON Merge Patch