Jackson is excellent at binding JSON to POJOs, but in real projects, JSON handling rarely ends at deserialization. After binding, we often still need to:
- query nested fields
- preserve unknown properties
- apply partial updates
- validate data against JSON Schema
That is when many codebases begin to bounce between two models—and the cost is not just conceptual, but structural.
- POJO for typed business logic
-
JsonNodefor structural operations
Every transition between POJO and JsonNode introduces friction: data copying, lost type information, or duplicated logic.
This article focuses on a question:
If Jackson already handles JSON-to-POJO mapping well, why do we still end up converting back to
JsonNodefor path access, patching, or validation?
And more importantly: what if we didn’t have to switch models at all?
Where the friction starts
A POJO is great for representing a stable Java model. But operations like JSON Path, JSON Patch, and JSON Schema are inherently structural.
That leads to a familiar pattern:
Order order = objectMapper.readValue(json, Order.class);
This is clean and natural. But later, requirements change:
- we need to read
$.customer.address.city - a
PATCHrequest updates/items/0/price - the payload contains fields the POJO does not declare
- validation rules are described as JSON Schema rather than Java annotations
So the code shifts to tree mode:
JsonNode root = objectMapper.readTree(json);
String city = root.path("customer").path("address").path("city").asText();
This works too. The problem is not either API on its own. The real friction comes from switching models inside the same workflow.
Over time, the codebase becomes fragmented:
- business logic talks in fields and getters
- structural logic talks in tree nodes
- patching introduces another abstraction
- validation may rely on yet another model
A more cohesive direction
In most JSON libraries, once the workflow becomes structural, we usually move into a dedicated tree model such as JsonNode.
SJF4J takes a different route: it removes the need for a separate tree model altogether.
Instead of introducing an AST like JsonNode, it treats ordinary Java objects themselves as nodes in a JSON-semantic tree.
In practice, that means:
- a JSON object can be represented by
Map,JsonObject,POJO, orJOJO - a JSON array can be represented by
List,JsonArray, Java arrays, orSet - a JSON value can be represented by
String,Number,Boolean,null, or adapted value types
That is the key design move.
Once these runtime objects participate in the same structural model, operations such as JsonPath, JsonPatch, and JsonSchema no longer need a separate tree representation in order to work. The object graph itself becomes the thing we navigate, patch, validate, and map.
So the real shift is not just “one more JSON library.” It is this:
instead of switching from Java objects to a JSON tree when the workflow becomes more structural,
SJF4J keeps the object graph itself structurally available from the beginning.
Why JOJO is a useful middle ground
One practical consequence of OBNT is JOJO: a typed Java object that also behaves like a JSON object.
public class Order extends JsonObject {
private String id;
private Customer customer;
private List<Item> items;
}
Then you can work with the same object in multiple ways:
Order order = new Sjf4j().fromJson(json, Order.class);
order.getId(); // typed access
order.getStringByPath("$.customer.address.city"); // path access
order.getString("sourceSystem"); // dynamic property
This is useful because many real payloads are only mostly stable.
In practice, upstream systems evolve faster than your Java model.
Some fields belong to the core Java model. Others are optional, evolving, or owned by upstream systems. A plain POJO can feel too closed for that. A raw tree can feel too loose. JOJO sits comfortably in between.
A small example
Imagine an external order payload like this:
{
"id": "A1001",
"customer": {
"name": "Alice",
"address": { "city": "Shanghai" }
},
"items": [
{ "sku": "P1", "price": 100 }
],
"sourceSystem": "partner-x"
}
With a closed POJO model, sourceSystem may be ignored unless it is modeled explicitly.
With JOJO:
String source = order.getString("sourceSystem");
String city = order.getStringByPath("$.customer.address.city");
It is a small difference in code, but a big difference in modeling style: typed fields and flexible JSON-style access can live together.
JsonPath becomes more than a convenience
Because the object graph itself is navigable, JsonPath is no longer an add-on—it becomes a natural way to interact with your data.
With direct field access:
String city = order.getCustomer()
.getAddress()
.getCity();
This is perfectly fine when the structure is fixed and shallow.
But once the data becomes deeper, more dynamic, or only partially known, path-based access can be much cleaner:
String city = order.getStringByPath("$.customer.address.city");
It also scales naturally to bulk queries:
List<Integer> prices = order.findByPath("$.items[*].price", Integer.class);
Or filtered queries:
List<String> skus = order.findByPath("$.items[?@.price >= 100].sku", String.class);
No, this is not the same as direct field access in absolute raw speed. But that trade-off is often worth it. Like many higher-level abstractions in Java, it introduces some overhead while saving code, reducing traversal boilerplate, and improving flexibility.
Learn more → Navigating (JSON Path)
Patch fits naturally too
Once the object graph is treated structurally, patching no longer feels bolted on.
JsonPatch patch = JsonPatch.fromJson("""
[
{ "op": "replace", "path": "/customer/name", "value": "Alice Zhang" },
{ "op": "add", "path": "/items/0/discount", "value": 15 }
]
""");
patch.apply(order);
Or direct path mutation:
order.ensurePutByPath("$.shipping.trackingNo", "SF123456");
This is especially useful for:
- HTTP PATCH endpoints
- partial state updates
- integration events
- targeted nested mutations
Learn more → Patching (JSON Patch)
Validation stays in the same flow
Schema validation also becomes easier to place in the workflow when it operates on the same runtime structure:
SchemaValidator validator = new SchemaValidator();
boolean valid = validator.validate(order).isValid();
The benefit is not only fewer conversion steps. More importantly, query, mutation, and validation all operate with the same structural semantics.
Learn more → Validating (JSON Schema)
Final thought
JSON in Java is not just about converting strings into objects.
It is about how those objects participate in the rest of your system.
By treating ordinary objects as part of a JSON-semantic tree, SJF4J removes an entire layer of indirection—and with it, a surprising amount of complexity.
Project links:
- GitHub: https://github.com/sjf4j-projects/sjf4j
- Docs: https://sjf4j.org

Top comments (0)