Skip to content

Writing

The trade-offs I weigh by default

PATCH vs. PUT, native vs. derived queries, versioning, and BOM-aware dependency upgrades — a tour of the design decisions I make the same way every time, and why.

All writing
3 min readDesign patternsAPI designJudgment

Good judgment is mostly a set of defaults you've stress-tested. Here are the decisions I make the same way every time, and the reasoning that makes them portable across domains.

PATCH vs. PUT

PATCH for partial updates with an explicit allow-list of patch paths; PUT for full replacement. The important word is explicit. I don't let arbitrary fields be patched — I maintain an allow-list, and I remove fields from it when they shouldn't be client-mutable.

A concrete example: midway through a record-sharing feature, we decided owner_id should no longer be patchable. The clean move was to remove /owner_id from the valid patch paths, delete the corresponding patch method, and then find and remove every test asserting the old behavior — using git diff to scope exactly what changed.

private static final Set<String> ALLOWED_PATCH_PATHS =
    Set.of("/recipientId", "/expiresAt");   // owner_id intentionally absent

An allow-list turns "what can a client change?" from an implicit, sprawling question into a single line you can review.

Native vs. derived vs. @Query

I decide between a native query, a derived query, and @Query based on complexity and performance, not habit. For read paths where latency matters, I'll write a bespoke native query with a projection that returns exactly the response shape — rather than loading a full entity graph and mapping it down.

The trigger is usually the generated SQL. When I see an N+1 in the Hibernate logs, that's the signal to collapse it:

// Projection interface — the query returns only what the API needs.
interface GroupPermissionView {
    Long getGroupId();
    String getAction();
    String getResourceKey();
}

The projection keeps the read path aligned to the response contract, so an internal model change can't silently leak into the API.

Versioning

Introduce `/v2` rather than break `/v1` consumers, and keep both working during the transition. Breaking changes are a promise you're making to every client at once; a new version lets them migrate on their own schedule. The cost is running two paths for a while, which is almost always cheaper than a coordinated flag-day.

Dependencies: be BOM-aware

Upgrades are where good intentions cause outages. My rule: prefer BOM-managed versions, and only pin when necessary — then verify the pin doesn't silently downgrade a transitive dependency and reintroduce a fixed CVE.

That exact trap bit a service I worked on: a JWT library had been pinned to a version that conflicted with the Spring Boot BOM, quietly undoing a security fix. An automated reviewer flagged the downgrade; I read the SCA report, wrote a remediation plan, and reconciled the pin against the BOM instead of reflexively bumping versions.

A version pin is a claim that you know better than the BOM. Sometimes you do — but you have to check, and you have to write down why.

The through-line

None of these are exotic. What ties them together is a small set of values: make the contract explicit, keep the system consistent, prove behavior with tests and logs, and change deliberately within a stated scope. The domain is incidental; the decision-making style is the thing worth carrying from one project to the next.