ExerciseProduction incident
Production incident
The endpoint that returns maps instead of DTOs
45 minintermediate3–10 yrs
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
The gateway pulls its routing rules from a config service on startup,
through a client that is generic so every team can reuse it:
List<RouteRule> rules = client.fetchAll();
Two tickets, filed three weeks apart, that nobody connected:
1. Startup fails with ClassCastException on the line that reads
rule.path(). The stack trace points into the gateway. The gateway has
not changed in months, and the config service is returning valid data.
2. After that was worked around, the rule cache started throwing
ClassCastException too — in a different class, on a read, with nothing
wrong on that line either.
Both are the same fact about Java generics, seen from two directions.
Find the root cause, fix both, and then answer the design question: what
would make the second bug impossible to write, rather than merely absent
from today's code?
What this teaches
- A method cannot build a T, because at runtime it does not have one
- An unchecked cast moves the failure to the caller's line, which is correct code
- A Class<T> is a real runtime object; a type argument is not — so pass the Class in
- A super type token recovers a generic type, and the {} is what makes it work
- A raw reference disables checking for every write through it
- Collections.checkedMap moves the failure back to the guilty line
Starter
Starter.javaOpen in playground
import java.lang.reflect.*;
import java.util.*;
/**
* Incident reproduction: the routing config client.
*
* The gateway pulls its routing rules from a config service on startup. The
* client is generic so every team can reuse it:
*
* List<RouteRule> rules = client.fetchAll(RouteRule.class);
*
* Two symptoms, reported as separate tickets, three weeks apart:
*
* 1. Startup fails with ClassCastException on the line that reads
* rule.path(). The stack trace points at the gateway. The gateway has
* not changed in months, and the config service returns valid data.
*
* 2. Once that was worked around by "just using maps", the rule cache
* started throwing ClassCastException too — in a different class, on a
* read, with nothing wrong on that line either.
*
* Both come from the same fact about Java generics. Find it, fix both, and
* then answer the design question: what would make the second bug impossible
* to write, rather than merely absent today?
*/
public class Starter {
/** What the gateway actually wants. */
record RouteRule(String path, int weight, boolean enabled) {}
/* ─────────── the transport layer, which you do not own ─────────── */
/**
* Hands back the decoded wire payload. It has no idea what your
* application types are, so everything is a map of primitives — exactly
* what a real HTTP/JSON client produces before mapping.
*/
static List<Map<String, Object>> wire() {
return List.of(
new LinkedHashMap<>(Map.of("path", "/orders", "weight", 10, "enabled", true)),
new LinkedHashMap<>(Map.of("path", "/payments", "weight", 5, "enabled", true)),
new LinkedHashMap<>(Map.of("path", "/legacy", "weight", 1, "enabled", false)));
}
/* ─────────── the mapper, which you do own ─────────── */
/**
* Turn the wire payload into `target`.
*
* Reads the component names off the record and calls its canonical
* constructor. This part works; it is only ever given the wrong target.
*/
@SuppressWarnings("unchecked")
static <T> T toRecord(Map<String, Object> row, Class<T> target) {
try {
RecordComponent[] components = target.getRecordComponents();
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
types[i] = components[i].getType();
values[i] = row.get(components[i].getName());
}
return (T) target.getDeclaredConstructor(types).newInstance(values);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("cannot map " + row + " to " + target, e);
}
}
/* ─────────── the generic client ─────────── */
static final class ConfigClient {
/**
* BUG 1 lives here.
*
* The signature promises a List<T>. At runtime this method has no T,
* so it cannot know what to build — and it quietly returns the wire
* maps instead. The compiler inserted a cast at the CALL SITE, so the
* failure surfaces there, in code that is correct.
*/
@SuppressWarnings("unchecked")
<T> List<T> fetchAll() {
return (List<T>) wire();
}
}
/* ─────────── the cache, added during the workaround ─────────── */
static final class RuleCache {
private final Map<String, List<RouteRule>> byTenant = new HashMap<>();
void put(String tenant, List<RouteRule> rules) {
byTenant.put(tenant, rules);
}
List<RouteRule> get(String tenant) {
return byTenant.getOrDefault(tenant, List.of());
}
/**
* BUG 2 lives here.
*
* Added in a hurry so the health endpoint could report "something is
* cached" for tenants whose rules had not loaded yet. The raw type
* switches type checking off completely, and nothing complains until
* somebody reads the entry back.
*/
@SuppressWarnings({"rawtypes", "unchecked"})
void markPlaceholder(String tenant) {
Map raw = byTenant;
raw.put(tenant, List.of("placeholder"));
}
}
public static void main(String[] args) {
boolean rulesMapped = false;
boolean cacheSafe = false;
System.out.println("--- symptom 1: startup ---");
ConfigClient client = new ConfigClient();
try {
List<RouteRule> rules = client.fetchAll();
for (RouteRule rule : rules) {
System.out.println(" route " + rule.path() + " weight " + rule.weight());
}
rulesMapped = rules.size() == 3;
} catch (ClassCastException e) {
System.out.println(" ClassCastException reading a rule:");
System.out.println(" " + e.getMessage());
}
System.out.println();
System.out.println("--- symptom 2: the cache ---");
RuleCache cache = new RuleCache();
cache.put("acme", List.of(new RouteRule("/orders", 10, true)));
cache.markPlaceholder("globex");
try {
for (RouteRule rule : cache.get("globex")) {
System.out.println(" cached route " + rule.path());
}
cacheSafe = true;
System.out.println(" read back cleanly");
} catch (ClassCastException e) {
System.out.println(" ClassCastException reading the cache:");
System.out.println(" " + e.getMessage());
}
System.out.println();
System.out.println("routing rules mapped to RouteRule : " + rulesMapped);
System.out.println("cache reads back without a CCE : " + cacheSafe);
System.out.println(rulesMapped && cacheSafe ? "PASS" : "FAIL");
}
}Run it locally:
cd exercises/java/generics/type-erasure/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Read fetchAll(). It returns (List<T>) wire(). What is T at the moment that cast executes?
Hint 2
The cast in fetchAll() never checks anything. Where does javac put the cast that DOES check? Look at the call site.
Hint 3
For bug 2: find the word `raw`. What does a raw Map reference let you write into a Map<String, List<RouteRule>>?
Hint 4
For the design question — you cannot make the compiler check a write through a raw type. So what could check it at runtime, and where in the JDK does that already exist?
Done when
- The gateway prints all three routes as RouteRule objects
- No ClassCastException anywhere in the run
- The cache rejects a wrong-typed write at the write, not at the next read
- A comment explains why passing Class<T> works when the type parameter does not
Solution
Show the solution — try it yourself first
Solution.javaOpen in playground
import java.lang.reflect.*;
import java.util.*;
/**
* Solution: the routing config client.
*
* Root cause, both tickets: a type argument does not exist at runtime.
*
* Bug 1 — fetchAll() promised List<T> and had no T to build with, so it
* returned the wire maps and let the caller's compiler-inserted
* cast take the blame. The fix is to pass the type IN, because a
* Class<T> is a real runtime object where T is not.
*
* Bug 2 — a raw Map reference switched type checking off, so a String
* entered a Map<String, List<RouteRule>> silently and the
* ClassCastException fired in whoever read it next. The fix is to
* delete the raw back door; the design fix is to make the write
* itself fail, which Collections.checkedMap does.
*/
public class Solution {
record RouteRule(String path, int weight, boolean enabled) {}
/* ─────────── the transport layer, unchanged ─────────── */
static List<Map<String, Object>> wire() {
return List.of(
new LinkedHashMap<>(Map.of("path", "/orders", "weight", 10, "enabled", true)),
new LinkedHashMap<>(Map.of("path", "/payments", "weight", 5, "enabled", true)),
new LinkedHashMap<>(Map.of("path", "/legacy", "weight", 1, "enabled", false)));
}
@SuppressWarnings("unchecked")
static <T> T toRecord(Map<String, Object> row, Class<T> target) {
try {
RecordComponent[] components = target.getRecordComponents();
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
types[i] = components[i].getType();
values[i] = row.get(components[i].getName());
}
return (T) target.getDeclaredConstructor(types).newInstance(values);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("cannot map " + row + " to " + target, e);
}
}
/* ─────────── FIX 1a: pass the type in ─────────── */
/**
* A super type token. The empty braces at the call site create an
* anonymous subclass, and that subclass's Signature attribute records the
* full generic type — which getGenericSuperclass() reads back. This is
* exactly how Jackson's TypeReference and Spring's
* ParameterizedTypeReference work, and why both make you write the {}.
*/
abstract static class TypeRef<T> {
final Type type;
TypeRef() {
Type superclass = getClass().getGenericSuperclass();
this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
}
}
static final class ConfigClient {
/**
* The plain fix, and the one to reach for by default: a Class<T> is a
* real object, so the method has the type at runtime.
*/
<T> List<T> fetchAll(Class<T> elementType) {
List<T> out = new ArrayList<>();
for (Map<String, Object> row : wire()) {
out.add(toRecord(row, elementType));
}
return out;
}
/**
* The token fix, needed when the type you want is itself generic and
* so has no Class object — List<RouteRule> being the example.
*/
@SuppressWarnings("unchecked")
<T> List<T> fetchAll(TypeRef<List<T>> ref) {
ParameterizedType listType = (ParameterizedType) ref.type;
Class<T> elementType = (Class<T>) listType.getActualTypeArguments()[0];
System.out.println(" token recovered element type: " + elementType.getSimpleName());
return fetchAll(elementType);
}
}
/* ─────────── FIX 2: close the raw back door ─────────── */
static final class RuleCache {
/**
* checkedMap is the design answer, not just the fix. It carries the
* Class objects the generic type could not, and rejects a bad value
* AT THE WRITE — so the exception names the line that is actually
* wrong instead of the next innocent reader.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private final Map<String, List<RouteRule>> byTenant =
Collections.checkedMap(new HashMap<>(), String.class, (Class) List.class);
void put(String tenant, List<RouteRule> rules) {
byTenant.put(tenant, rules);
}
List<RouteRule> get(String tenant) {
return byTenant.getOrDefault(tenant, List.of());
}
/** No raw reference, and nothing to pollute: a placeholder is empty. */
void markPlaceholder(String tenant) {
byTenant.putIfAbsent(tenant, List.of());
}
/** Only here to prove the back door is now loud rather than silent. */
@SuppressWarnings({"rawtypes", "unchecked"})
boolean rejectsWrongKeyType(String tenant) {
Map raw = byTenant;
try {
raw.put(42, List.of());
return false;
} catch (ClassCastException e) {
System.out.println(" raw write rejected at the write: "
+ e.getClass().getSimpleName());
return true;
}
}
}
public static void main(String[] args) {
boolean rulesMapped = false;
boolean cacheSafe = false;
System.out.println("--- symptom 1: startup ---");
ConfigClient client = new ConfigClient();
List<RouteRule> rules = client.fetchAll(RouteRule.class);
for (RouteRule rule : rules) {
System.out.println(" route " + rule.path() + " weight " + rule.weight());
}
rulesMapped = rules.size() == 3 && rules.get(0).path().equals("/orders");
System.out.println();
System.out.println("--- the same call through a super type token ---");
List<RouteRule> viaToken = client.fetchAll(new TypeRef<List<RouteRule>>() {});
System.out.println(" rules via token: " + viaToken.size());
rulesMapped = rulesMapped && viaToken.equals(rules);
System.out.println();
System.out.println("--- symptom 2: the cache ---");
RuleCache cache = new RuleCache();
cache.put("acme", List.of(new RouteRule("/orders", 10, true)));
cache.markPlaceholder("globex");
for (RouteRule rule : cache.get("globex")) {
System.out.println(" cached route " + rule.path());
}
System.out.println(" globex entry read back cleanly, size "
+ cache.get("globex").size());
cacheSafe = cache.rejectsWrongKeyType("globex");
System.out.println();
System.out.println("routing rules mapped to RouteRule : " + rulesMapped);
System.out.println("cache rejects pollution at write : " + cacheSafe);
System.out.println(rulesMapped && cacheSafe ? "PASS" : "FAIL");
}
}Stretch
Add a second config type whose rules are themselves generic —
List<Map<String, RouteRule>> — and fetch it. Class<T> is no longer enough:
there is no Class object for that type. Build the call with the super type
token instead, and explain in a comment what getGenericSuperclass() reads
and why removing the {} breaks it.