Background reading on the design decisions behind this library. For "what exists," see reference.md; for "how do I," see how-to.md.
The DTOs (Answer, Question, EvaluateRequest, EvaluateResponse, Usage, RequestId)
started out annotated directly with @JsonTypeInfo/@JsonSubTypes and hard-wired to
Jackson 2's ObjectMapper. That meant every consumer of the client was also a forced consumer of
Jackson 2, and the DTOs themselves couldn't be reused (e.g. to serialize the same payloads onto a
Kafka topic) without dragging in java.net.http-specific code too.
Splitting the DTOs into typesafe-java-core with zero Jackson dependency, and moving the type
discriminator logic into private mixins inside jackson2/jackson3 (ObjectMapper.addMixIn /
JsonMapper.Builder.addMixIn), means:
corealone is a valid dependency for anything that just needs the payload shapes.- Supporting a third JSON library later is "add a fourth codec module," not "touch the DTOs."
- The DTOs stay exactly what they look like: plain records and a sealed interface, with no clue which serialization library (if any) is reading them.
See ADR 0001 for the full decision record.
core cannot declare a compile dependency on jackson2/jackson3 or on client-jdk —
any of those choices would undo the whole point of splitting them out. ServiceLoader lets
TypeSafeClient stay agnostic to both while still getting real implementations automatically
the moment one codec module and one transport module are on the classpath, the same pattern the
JDK itself uses for java.sql.Driver or java.nio.file.spi.FileSystemProvider. The tradeoff: a
missing codec or transport module fails at TypeSafeClient.Builder.build() time with a runtime
IllegalStateException, not at compile time — deliberately, since a compile-time check here
would mean picking one codec/transport as "the real dependency," which is exactly what this
design avoids.
TypeSafeClient originally called java.net.http.HttpClient directly. Abstracting that behind
HttpTransport — mirroring JsonCodec — means someone who wants Apache HttpClient, OkHttp, or a
mocked transport for tests can implement one interface (post/get, both already
CompletableFuture-returning) instead of forking the retry/backoff logic.
Once that abstraction exists, TypeSafeClient itself has no HTTP-library dependency any more —
its only import from java.net is URI, which every JDK module already has. That removed the
original reason for a separate client module (keeping core free of java.net.http), so
TypeSafeClient/ApiKey/TypeSafeException live in core next to the DTOs: one fewer module
to version and depend on, with core exactly as dependency-free as before. See
ADR 0001 for the full decision record.
It started out public final class TypeSafeClient. Mockito 5's default (inline) mock maker
already mocks final classes, so finality was never actually blocking a caller from unit-testing
code that depends on TypeSafeClient — but it did block a different, legitimate use: a
decorator. final means nothing can implements/present itself as a TypeSafeClient, so a
caller who wants to wrap one with caching, metrics, a circuit breaker, or anything else in the
classic Decorator shape has no supertype to implement — they'd have to invent their own
interface with the same three methods and get every call site to depend on that instead of on
TypeSafeClient directly.
Making it an interface costs nothing observable at existing call sites: TypeSafeClient.builder(key).build()
still type-checks and behaves identically, since Builder.build() always returned the interface
type as far as callers could tell. What moved is the implementation — the retry/backoff/header/
decode logic, previously TypeSafeClient's own body, now lives in DefaultTypeSafeClient, the
only concrete TypeSafeClient this library produces. A consumer can now write
class CachingTypeSafeClient implements TypeSafeClient and hand it anywhere a TypeSafeClient
was expected.
Builder moved with it, onto DefaultTypeSafeClient rather than staying on the TypeSafeClient
interface: constructing a DefaultTypeSafeClient — picking defaults, discovering a
HttpTransport/JsonCodec via ServiceLoader — is that class's own concern, not something a
pure contract interface should carry. That required making DefaultTypeSafeClient itself
public (a nested class can't be more accessible than its enclosing class), so it's no longer
hidden — but TypeSafeClient.builder(apiKey) still exists as a one-line delegating static method
on the interface, so nothing at the call site changes; a consumer only sees DefaultTypeSafeClient
by name if they explicitly go looking for it.
Once TypeSafeClient became an interface (see above), Mockito.mock(TypeSafeClient.class) was
already enough to stub evaluate()/listModels() — so RecordingTypeSafeClient isn't there to
make something possible that wasn't. It's there so a consumer's test doesn't need a Mockito
dependency at all, and so the two or three lines of "queue a response, assert on what was sent"
every such test wants don't get rewritten by hand each time. It's deliberately a plain FIFO
(enqueueEvaluate/enqueueModels) rather than a matcher-based expectations DSL: a test already
controls call order — it's the one deciding when to call evaluate()/listModels() — so
matching by request content would only restate what the test already knows.
Issue #2 flagged that reading an answer back
means Map<String, Answer> plus a manual (Answer.Noul)-style cast, and that a Noul question
with no criteria still needed an empty Map.of() at the call site (since fixed — Question.noul
now has a criteria-less overload too). MappingTypeSafeClient fixes the cast: a caller's own
record carries @Noul/@Choice/@Score on its components, and gets a populated instance of
that same record back.
Three ways to build that mapping, in ascending complexity: a fluent builder (no annotations,
just explicit .noul("isUrgent", "...") calls mapped to record positions by hand — doesn't
remove the cast, only moves it into the builder's own return type); reflection over
Class#getRecordComponents() (no new build step, matches how the rest of this project already
avoids codegen — jackson2/jackson3's polymorphism is hand-written mixins, not generated); or
an annotation processor generating a real mapper class at compile time (fully typed at compile
time, zero reflection cost per call, but a new javac-time dependency and generated-sources
step nothing else in this repo has). Reflection won: even repeated, its cost is dwarfed by the
network round trip each call wraps, and it keeps mapping's dependency footprint identical to
every other module here (core only).
That said, MappingTypeSafeClient still caches each record type's reflection metadata — its
per-component question/answer mapping and canonical constructor — the first time that type is
used, keyed by Class on the instance. A single call's reflection cost being noise doesn't mean
redoing it on every call is free; caching it is nearly free to add (one ConcurrentHashMap) and
turns "noise per call" into "noise once per record type," which also means a caller only pays
the validation cost (see below) once, not on every request.
@Option only exists nested inside @Choice#options() (@Target({}), not usable on its own) —
Question.Choice#criteria() is a Map<String, String>, and a Java annotation attribute can't
be a Map; an array of a small carrier annotation is the standard workaround. @Noul/@Score
map to a double component (the raw Answer.Noul#noul()/Answer.Score#score() value, not a
derived boolean/enum) deliberately: a probability-to-boolean threshold is a policy decision
this library shouldn't make on a caller's behalf.
The scalar mapping (double/String) drops Answer's other fields — probabilities(),
confidence(), and (for Score) legend() — which is fine for the common case but was a real
gap for a caller who wants them: the only escape hatch was dropping evaluateTyped for a plain
evaluate() call and going back to Map<String, Answer>. Rather than a second family of
confidence-carrying annotations/types, each component's type is simply allowed to be the full
Answer.Noul/Answer.Choice/Answer.Score as an alternative to the scalar —
componentMappingFor picks an identity extraction instead of unwrapping the scalar when it sees
the full type. Same annotation, same validation path, no new concepts.
The natural-looking ask — MappingTypeSafeClient.builder(apiKey)...build(), mirroring
TypeSafeClient.builder(apiKey) — was rejected. TypeSafeClient.builder works because
TypeSafeClient has exactly one production implementation to build. MappingTypeSafeClient is
a decorator, meant to wrap any TypeSafeClient (a plain one, one already wrapped in caching,
a FailingTypeSafeClient for testing, a test double) — a builder that constructs its own
DefaultTypeSafeClient internally would bake in "wrap a fresh default client" as the only path,
against the entire reason the decorator shape exists (see "Why TypeSafeClient is an interface,
not a final class" above). It would also duplicate DefaultTypeSafeClient.Builder's whole
surface (httpTransport, jsonCodec, endpoint, maxRetries, initialBackoff) as forwarding
methods that go stale the moment the original gains an option this copy doesn't.
What shipped instead is a single addition to the existing Builder:
build(Function<TypeSafeClient, T> decorate), one line (decorate.apply(build())) that applies
a decorator to the client it just built and returns T instead of the plain TypeSafeClient —
no cast needed to reach evaluateTyped. It's generic, so core never needs to know
MappingTypeSafeClient exists, and it's purely additive: the plain
MappingTypeSafeClient.decorate(anyDelegate) static factory still works for every case this
doesn't cover — there's no public constructor to call instead.
Stacking more than one decorator needs no extra API either — Function#andThen (stdlib) composes
them, so builder(apiKey).build(caching.andThen(MappingTypeSafeClient::decorate)) already works.
EvaluateRequest.state() used to be a bare Object — "whatever the caller's JsonCodec can
serialize." But docs.typesafe.ai/concepts/state
documents state as exactly three shapes: a string, a JSON object, or an array of text values —
not open-ended JSON. Object was strictly looser than the real API contract: a caller could pass
a List<Integer> or a custom record and it would compile, then fail (or silently misbehave)
against the actual API. State (Text/Fields/Messages) makes the three valid shapes a
compile-time fact, the same way Answer/Question already do for their own domains.
Unlike Answer/Question, state has no type discriminator on the wire — the API tells the
three shapes apart by their raw JSON type (string vs. object vs. array), not a tag field. So each
codec's State handling is a serializer that writes the variant's raw value directly
(gen.writeString(...) / gen.writeObject(...)/writePOJO(...)), not a type-keyed mixin like
Answer/Question use. There's also no deserializer: state only ever appears on
EvaluateRequest, which this client only ever writes, never reads back.
They used to declare throws IOException, InterruptedException, mirroring
java.net.http.HttpClient's own convention. That was an inconsistency with TypeSafeException
itself (already unchecked, for non-2xx statuses): the same call had two failure modes treated
differently — one a caller could ignore, the other forced onto every call site's signature or a
try/catch. A connection failure, a timeout, and the calling thread being interrupted are now
TypeSafeException.Connection/.Timeout/.Interrupted — all unchecked, alongside the
per-status subclasses. See ADR 0002 for the full
decision record.
evaluate/evaluateAsync retry 408, 429, and any 5xx up to 5 times, doubling the backoff
from an initial 500ms each time (500ms, 1s, 2s, 4s, 8s). Any other status — including a retryable
one that outlasts the retry budget — surfaces immediately as
TypeSafeException rather than being swallowed or retried indefinitely: a caller should always
be able to tell "this request permanently failed" from "this request is still in flight,"
and an unbounded retry loop against a struggling upstream only makes the overload worse.
They started as private static final constants. Making them Builder options costs three
setters and three fields, and buys two things: pointing at a staging endpoint without an
environment-specific subclass, and fast unit tests — TypeSafeClientTest doesn't need to wait
out a real 500ms+ backoff because nothing forces it to use the production default.
A naive CompletableFuture.supplyAsync(() -> evaluate(request)) would burn one thread per
in-flight request, blocked on Thread.sleep during backoff. evaluateAsync instead chains off
HttpTransport.post's returned future and schedules retries via CompletableFuture.delayedExecutor,
so a backoff wait never blocks a thread — the same retry policy, without the thread cost, which
matters once callers are firing many requests concurrently. This only holds if the
HttpTransport implementation's post is itself genuinely non-blocking (JdkHttpTransport is,
since it delegates to HttpClient.sendAsync); a transport backed by a blocking-only HTTP library
has no non-blocking send to chain off and has to fall back to a thread-per-call post.