I can tolerate legacy code.
I really can. I understand how software grows. Deadlines hit, MVPs turn into production systems overnight, and compromises get made under pressure. Every engineer inherits things they wouldn’t have built that way. You roll up your sleeves, write tests around the danger zones, and slowly refactor it toward sanity.
For the past four years, that is exactly what I’ve been doing with a sprawling legacy backend.
Controllers with 10,000 lines of spaghetti. Zero architectural flow. God classes that know about everything from the database connection down to the exact email template string. I took it in stride.
Then came the JSON.
The Tri-Library Circus
When I started auditing our dependencies, the first thing that hit me was the sheer, unadulterated dependency bloat. We had dozens of libraries doing the exact same thing in slightly different, subtly incompatible ways.
For JSON alone, the codebase was pulling in:
Jackson(which comes with Spring Boot by default)FlexJSON(an ancient serialization library that hasn’t been relevant in a decade)org.json(because someone needed aJSONObjectin 2017 and didn’t bother checking what was already on the classpath)- And to top it off: an Android utility library sitting in a server-side Spring backend. Why? Because someone wanted an Android-specific Base64 or JSON helper and dragged in a mobile framework dependency rather than using
java.util.Base64or Jackson.
Every developer who touched the codebase over a decade had just imported whatever library they remembered from their first programming job. One controller serialized with FlexJSON, another deserialized with org.json, and Spring was trying to wire everything in the middle with Jackson.
Over months of systematic cleanup, I audited the dependency tree and stripped out the dead weight. The backend artifact size dropped from 114 MB down to 108 MB, and finally down to 94 MB. Pruned unnecessary libraries, banned duplicate parsers, and consolidated everything onto Jackson—the tool Spring already provides out of the box.
You’d think the worst was behind us. But the dependency bloat was merely a symptom. The real horror lived in the payloads.
The Double-Encoding Abomination
Take a look at what was being passed into our HTTP request bodies (and regurgitated right back out in responses):
{
"code": 200,
"message": "success",
"data": "{\"userId\":\"usr_98412\",\"tier\":\"enterprise\",\"settings\":\"{\\\"theme\\\":\\\"dark\\\",\\\"notifications\\\":true}\"}"
}
Look at data. Look at settings.
It is not an object. It is a JSON-encoded string, sitting inside another JSON-encoded string, sitting inside a JSON payload.
HTTP Body
└─ JSON Object
└─ "data": String (Escaped JSON)
└─ "settings": String (Double-Escaped JSON)
Someone took an object, serialized it to a string using one library, stuffed that raw string into another map, serialized that map using a second library, and shipped it over HTTP.
On the server, instead of letting Spring do its job and deserialize the request cleanly into a typed DTO, the controller had to perform manual gymnastics:
- Spring deserializes the outer HTTP body into a generic wrapper or map.
- The controller extracts
dataas a rawString. - It manually invokes a second JSON parser to turn that string into an intermediate object or map.
- To get a nested preference, it extracts
settingsas yet another rawString. - It invokes a third parser call to deserialize that string into a settings object.
If an unescaped quote or an encoding hiccup occurs anywhere along the line, the entire deserialization chain explodes with a syntax error that no schema validator can catch.
Why? Why would anyone ever do this?
When I dug into the commit history and asked around, the justification finally emerged:
“Our data is variable, and we can’t easily support a generic
Map<String, Any>inside a typed DTO! The client libraries don’t deserialize arbitrary maps cleanly, so we had to make it a string.”
I am not an app developer. I spend my days deep in backend architecture, databases, and network protocols. But if I were an app developer, I would never let this pass code review. This is plain stupidity.
First of all, turning a JSON object into a string does not solve the client’s parsing problem. It merely kicks the can down the road. The mobile app still has to parse that JSON into usable fields—except now, instead of letting its HTTP client decode the response in one standard pass, it has to parse the outer payload, extract the string, and manually fire up another parser instance to decode the inner payload. You haven’t simplified the client; you’ve saddled it with a brittle two-pass decode and manual string-unescaping gymnastics.
Second, dealing with variable or arbitrary JSON is a solved problem across every modern language and ecosystem:
- In iOS (Swift): While
Decodableis strictly typed by design, you don’t break the wire format to appease the compiler. You either useJSONSerializationto parse into[String: Any], or you drop in anAnyCodablewrapper to decode dynamic structures cleanly. - In Android (Gson): You don’t stringify; you parse variable structures directly into
JsonObjectorJsonElement. Gson handles arbitrary JSON trees natively without requiring you to mangle the HTTP body into a string. - In Spring / Jackson: You deserialize arbitrary trees into
JsonNodeorMap<String, Object>. - Or better yet, polymorphism: If the data varies between known shapes, you use standard polymorphic serialization with a discriminator field (e.g.,
@JsonTypeInfoin Jackson) instead of abandoning typing altogether.
Punting fundamental deserialization ignorance into the wire protocol—and expecting the entire backend, database, and client apps to swallow the cost—is inexcusable.
The Leaning Tower of Backslashes
It gets worse. Once you normalize encoding JSON into strings, that disease inevitably leaks into your persistence layer.
I have opened database tables in this system and found records containing literally more backslashes (\) than actual data.
Because what happens when a service reads a stringified JSON field, wraps it in another object, serializes it again, and saves it back? The backslashes compound exponentially:
Pass 1: "{\"key\": \"val\"}"
Pass 2: "{\\\"key\\\": \\\"val\\\"}"
Pass 3: "{\\\\\\\"key\\\\\\\": \\\\\\\"val\\\\\\\"}"
A few roundtrips through buggy update pipelines, and you end up with single database values drowning in thousands of consecutive backslashes. It is a mathematical monument to bad design: $2^n$ escape characters multiplying with every layer of indirection.
It broke parsers completely. JSON serialization libraries are written by competent engineers who optimize for the real world; they do not write test cases for a single field containing four thousand backslashes because no sane person expects this level of architectural rot. Deserializers would hang, memory would spike, and the logs would drown in cryptic unescaping errors.
“It’s a Convention”
The bad code itself isn’t what drives you mad. What truly drains your life force as an engineer is the conversation that follows when you try to fix it.
When I flagged this and started standardizing endpoints to clean, nested JSON, the response from some peers wasn’t relief. It was pushback:
“Well, that’s just the convention we’ve always used here. Just document it in Swagger and move on.”
Convention? Since when is escaping JSON inside JSON a “convention”?
Calling broken engineering a “convention” is how bad code becomes permanent code. It’s the ultimate defense mechanism for people who stop caring about craftsmanship. Instead of admitting that a practice is an anti-pattern that violates basic API design and costs every consumer CPU cycles and sanity, you slap the word “convention” on it to make it immune to criticism.
Even for an MVP, this is indefensible. An MVP means minimal scope, not negative competence. Writing a clean nested POJO takes the exact same number of keystrokes as serializing an object to a string and shoving it into a wrapper.
The Cost of the Fight
The irony of modern backend work is that writing the fix usually takes ten minutes. Consolidating the dependencies took an afternoon. Replacing the double-encoded hack with a proper DTO took thirty lines of code.
The exhausting part—the part that leaves you staring at the ceiling at 6 PM wondering why you write software—is having to spend two weeks in review meetings debating whether valid JSON is better than doubly-escaped string blobs.
And it’s never just this one endpoint. You face this exact battle on every single decision.
Every refactor, every decoupled service, every attempt to remove deadweight dependencies turns into an uphill philosophical trial. You find yourself repeatedly having to explain why a blatant anti-pattern is bad. You find yourself trying to articulate what a “code smell” is to someone who fundamentally cannot smell it.
How do you explain that something stinks to someone who has lived in the dumpster so long they think the odor is just company culture? What do you even say at that point?
When someone doesn’t understand basic cohesion, coupling, or data hygiene, every technical critique gets flattened into mere personal preference: “Well, it works, doesn’t it?” Yes, a car held together with duct tape and zip ties can roll down the street, but that doesn’t mean it’s roadworthy.
Fixing legacy code isn’t just about refactoring syntax; it’s an exhausting psychological war against the normalization of mediocrity. If you see a stringified JSON blob living inside a JSON payload in your codebase, don’t document it. Don’t call it a convention. Kill it.