Java notes
API Evolution: Keep Wire Contracts Stable for Java Clients
Tolerant readers, batch error handling, and golden-file tests
What counts as the contract
- Field names, types, and optionality.
- HTTP status codes and which situations produce them.
- The error envelope: its fields and their meaning.
- Batch semantics: partial failures, ordering, and duplicates.
- Behavior when a request omits optional parameters, such as projections.
Be a tolerant reader
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ErrorEnvelope(int status, String message, String code, JsonNode errorDetails) {
public ErrorEnvelope {
message = message == null ? "" : message;
}
}
ignoreUnknown = trueprotects you from new server fields.errorDetailsstays a rawJsonNode. If the server's details are free-form, do not force them into a fixed class. Keep them untyped, and let the caller interpret them.- Missing
messagebecomes an empty string, so downstream code does not need null checks.
Classify errors in one place
public enum ErrorKind { CLIENT_ERROR, NOT_FOUND, UNPROCESSABLE, SERVER_ERROR, UNKNOWN }
public static ErrorKind classify(int status) {
if (status == 404) return ErrorKind.NOT_FOUND;
if (status == 422) return ErrorKind.UNPROCESSABLE;
if (status >= 400 && status < 500) return ErrorKind.CLIENT_ERROR;
if (status >= 500 && status < 600) return ErrorKind.SERVER_ERROR;
return ErrorKind.UNKNOWN;
}
Handle batch responses carefully
public static <V> BatchOutcome<V> split(Map<String, V> results, Map<String, ErrorEnvelope> errors) {
Map<String, V> ok = new LinkedHashMap<>(results);
Map<String, ErrorKind> failed = new LinkedHashMap<>();
errors.forEach((id, env) -> {
ok.remove(id);
failed.put(id, classify(env.status()));
});
return new BatchOutcome<>(ok, failed);
}
public record BatchOutcome<V>(Map<String, V> succeeded, Map<String, ErrorKind> failed) { }
Server-side rules for safe evolution
- Add fields as optional. Never remove or repurpose a field without a deprecation period.
- Do not change status codes for existing situations.
- Do not change the shape of the error envelope. Add to it instead.
- Be careful with requests that send no projection or field selection. Defaults are part of the contract.
- Version deliberately when you must break something, and announce it.
Golden-file tests
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ErrorEnvelopeTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void parsesCurrentShape() throws Exception {
String json = """
{"status":422,"message":"invalid","code":"VALIDATION","errorDetails":{"field":"email"}}
""";
ErrorEnvelope env = mapper.readValue(json, ErrorEnvelope.class);
assertEquals(422, env.status());
assertEquals("email", env.errorDetails().get("field").asText());
}
@Test
void ignoresFieldsAddedLater() throws Exception {
String json = """
{"status":404,"message":"gone","futureField":{"x":1}}
""";
assertEquals(404, mapper.readValue(json, ErrorEnvelope.class).status());
}
@Test
void missingOptionalFieldsDoNotFail() throws Exception {
ErrorEnvelope env = mapper.readValue("{\"status\":500}", ErrorEnvelope.class);
assertEquals("", env.message());
assertNull(env.code());
}
}
Failure modes
- A status change (for example 400 to 422) with no announcement.
- A strict client parser that rejects new fields.
- Error details forced into a typed structure that drops data.
- Batch handlers that fail the whole batch or hide partial failures.
- Requests without projections returning a different shape than before.
Demo to build
Takeaways
- The contract includes errors, status codes, and batch behavior.
- Clients should be tolerant readers and keep free-form details untyped.
- Golden-file tests turn accidental contract changes into visible diffs.