JSON to Java Class: The Generator Gives You a Skeleton, Production Needs Five More Things
Bottom line: A hands-on look at what a JSON-to-Java generator really emits — numbers become Double, nested objects become static inner classes, and not a single Jackson annotation is produced. Money precision, field renaming, type erasure, unknown fields, and dates are the five things you must add yourself, with copy-paste code for each.

The short version: a generator's output compiles immediately but is not production-ready. Our generator's measured defaults map every JSON number to Double, turn nested objects into static inner classes, and emit no Jackson annotations at all. Money precision, field renaming, type erasure, unknown fields, and dates are five things it will never do for you.
Turning an API response into Java classes is mechanical work: read the JSON, copy the fields, wrap them in getters and setters. The cost of mechanical work is the invisible knife — a property name copied wrong does not fail to compile. The field silently arrives null, and you find out in a production log. Generating the class structure exists to align names and types from day one. But a generator can only align what is visible in the sample.
1. Run the generator first: what does it actually give you
Paste a representative response into our JSON to Java tool:
{
"order_id": 202611040001,
"buyer": {
"name": "王五",
"vip": true
},
"items": [
{ "sku": "A-1", "price": 19.99, "qty": 2 }
],
"created_at": "2026-11-04T09:30:00+08:00"
}
The generated skeleton (default options, useLombok off):
public class Root {
private Double orderId;
private Buyer buyer;
private List<Item> items;
private String createdAt;
public Double getOrderId() { return orderId; }
public void setOrderId(Double orderId) { this.orderId = orderId; }
// ...remaining getters/setters follow the same pattern
}
Buyer and Item are separate static inner classes. The mapping rules, one by one:
| Shape in the JSON | Generated Java type | Notes |
|---|---|---|
"name": "王五" |
String |
Strings always become String |
"qty": 2 |
Double |
Every number becomes Double, see section 2 |
"vip": true |
Boolean |
Boxed type, null allowed |
"buyer": {...} |
Buyer (static inner class) |
Nested objects become their own class |
"items": [...] |
List<Item> |
Element type taken from the first element |
order_id |
orderId |
snake_case converted to camelCase |
Class names get PascalCase (Root), fields get camelCase (orderId), and collisions with Java keywords (class, new, int) get a suffix appended automatically. Three options exist: packageName, className, and useLombok, which switches between @Data and handwritten getters/setters. With Lombok on, the whole block of boilerplate collapses into a few annotations.
Two boundaries of this skeleton are worth knowing in advance. First, it generates no Jackson/Gson annotations at all — no @JsonProperty, no @JsonIgnoreProperties, no @JsonFormat, because annotations decide how deserialization executes, and a sample payload cannot reveal your execution policy. Second, there is no reverse conversion: deriving JSON from a Java class is out of scope, the same one-way design as the JSON to TypeScript path.
2. Fix number one: Double is not a type for money
The generator maps "price": 19.99 to Double. It compiles, it assigns, and it is still the wrong type. Binary floating point cannot represent 0.1, which means it cannot represent most decimal fractions:
System.out.println(19.99); // 19.99 — looks fine
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
Printing 19.99 alone looks clean because Java applies shortest-representation prettifying; the error surfaces the moment the value enters arithmetic. All three engineering write-ups on this topic agree: prices, amounts, and rates should be BigDecimal, constructed from a string — new BigDecimal("19.99"), not new BigDecimal(19.99), which carries the double's error straight in.
The mechanical fix is a post-generation pass: replace Double with BigDecimal for money fields only. A related trap on the same root: integers beyond 2^53 (order numbers, snowflake IDs) routed through Double lose their trailing digits silently — a 20-digit number losing its last three digits is a measured, reproducible outcome, and the mechanism is the same one behind why JSON loses big-integer precision. Map those fields to String or Long instead and let the digits travel as text.
| Field's real meaning | Generator gives | Change to |
|---|---|---|
| Money, rates | Double |
BigDecimal (string constructor) |
| Order numbers, IDs (may exceed 2^53) | Double |
String or Long |
| Counts, page numbers | Double |
Integer / Long |
| Genuinely scientific floats | Double |
keep |
3. Fix number two: mismatched names, pinned by @JsonProperty
Converting order_id to orderId is a well-meaning guess: the generator assumes you want camelCase on the Java side. Jackson, however, matches JSON keys against field names literally by default. When they don't match, nothing fails — orderId just stays null. Two fixes:
Per field — add @JsonProperty, one annotation pins one mapping:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
Global — switch the ObjectMapper's naming strategy and no per-field annotation is needed anywhere in the project:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
Choose by surface area: one fixed API, use field-level; a whole team talking to snake_case services, use the global strategy. One negative fact deserves its own paragraph here — a wrong name fails as a silent null, not as an exception. The jsonformater tool page treats this as the very reason generators exist: mechanical copying is exactly where this error rate peaks. So after adding the annotations, write at least one assertion test that key fields are non-null.
4. Fix number three: type erasure — never deserialize into a bare List
The generator hands you the field declaration List<Item>, but the thing that actually fills that list is Jackson. Deserializing at the top level like this falls straight into the type-erasure trap:
// Wrong: elements are LinkedHashMap at runtime
List<Item> items = mapper.readValue(json, List.class);
// Right: TypeReference preserves the generic parameter
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) compiles and runs, but Java generics exist only at compile time. At runtime Jackson sees just List, so the elements land as LinkedHashMap. The error does not fire immediately — it fires at the first item.getSku() somewhere else, throwing a ClassCastException far away from where the mistake was made. Three correct forms, by scenario:
// 1. The top level is the array
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. A field of a top-level object
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. Explicit JavaType construction
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
On the Gson side the equivalent is TypeToken. All three guides list this as its own item, because it fails late and reports far away.
5. Fix number four: unknown fields — don't let API evolution break deserialization
An API adding a field is the most ordinary kind of evolution. Jackson's default, though, is to throw UnrecognizedPropertyException on unknown fields — the provider adds one field and your service starts erroring. Turn it off at two levels:
// Class level: tolerate unknowns on this class only
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// Global: the whole mapper tolerates them
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Once off, a mirror problem appears: tolerance also silences misspelled field names, opening the "silent null" failure mode from the previous section all the way through. The middle ground is to run production with tolerance off and the test suite with it on, so misspellings surface during testing. Gson ignores unknown fields by default — that is why it feels effortless for quick integrations, and the price of that comfort is never knowing what the provider added.
6. Fix number five: dates and the no-arg constructor
The generator maps created_at to String, which is actually the safest default: in JSON a date is just a string, and the wire format could be ISO-8601, a Unix timestamp, or something custom — the sample cannot tell you which one you want. When you need a real date type, add two lines:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Emit ISO-8601 on serialization instead of timestamp arrays
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
The no-arg constructor is the other mandatory check: Jackson deserializes by creating the object through it, then setting fields one by one. Write a parameterized constructor and the default one disappears — deserialization fails immediately. With Lombok, add @NoArgsConstructor; with a Java 17 record, hand the whole problem to Jackson 2.12+'s record support, which matches by constructor parameters and removes the requirement entirely.
7. Two production concerns: reuse the ObjectMapper and validate input
The generator gives you classes; two architectural items remain at runtime. First, an ObjectMapper is expensive to create and thread-safe — share one instance across the whole application. Calling new ObjectMapper() inside a method is the most common performance anti-pattern, and the instance Spring Boot injects by default exists precisely for this reason. Naming strategy, JavaTimeModule, and unknown-field policy all belong on that one instance, not scattered across call sites.
Second, successful deserialization says the types matched, not that the values are legal. A negative price, a zero quantity, a malformed email — Jackson does not care. Input validation belongs to Bean Validation (hibernate-validator), added as annotations on the generated skeleton:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
In Spring, @Valid intercepts the request before it reaches business logic. This layer is something a generator can never provide — it can read what a field looks like from the sample, but not what business constraint the field carries.
8. Where generation ends and hand-writing begins
When to generate and when to hand-write is a clear line:
- Use the generator: responses with many fields, one-off third-party integrations, rapid prototypes. It eliminates exactly one error class — mechanical copying mistakes — and hands you a name-and-type-aligned skeleton in seconds.
- Hand-write or retrofit: money and long integers need type changes, naming strategy needs a global decision, core domain objects need validation annotations. These are business decisions a sample payload cannot make.
- Shared precondition for both: confirm the source JSON is valid first. A generator happily produces classes from a broken payload and leaves the trap for deserialization time; run it through our validation page before pasting. Ten seconds, either way.
The generator covers "zero to skeleton." The five fixes in this article cover "skeleton to production." The order cannot be reversed, and neither step can be skipped.
FAQ
Can the generated classes be used in Spring Boot directly?
At compile time, yes. Before runtime, three additions are worth making: money fields to BigDecimal, a naming strategy or @JsonProperty for snake_case APIs, and @JsonIgnoreProperties(ignoreUnknown = true) to survive API evolution. Bean Validation annotations follow your business rules.
Why does the generator map every number to Double instead of long or BigDecimal?
A sample can distinguish "integer vs. decimal" but not business meaning. A 2 might be a quantity or an ID; a 20-digit order number and ¥19.99 are both just numbers in JSON. Leaving semantic decisions to the user is a reasonable boundary for a forward generator — the cost is that the replacement table in section 2 is yours to walk through.
Lombok's @Data or handwritten getters/setters — which one?
If the team already uses Lombok, take @Data for the least boilerplate. If you would rather avoid a compile-time plugin or conflicts with other annotation processors, hand-write them. Jackson does not care either way; it looks at getters/setters or field visibility.
Nested objects become static inner classes — does that matter?
Not to Jackson; a static inner class can be instantiated independently. For long-term maintenance, most teams pull them out as top-level classes in a package for reuse and isolated testing. The generator's inner-class layout is built for quick inspection, not for a living codebase.
A List field throws ClassCastException during deserialization — how do I locate it?
First check whether the deserialization used a bare List.class: the elements are actually LinkedHashMap and the error fires when a field is read. Switch to TypeReference. If it still fails, paste the exception into our error message lookup page and follow the entry for your language.