Skip to content

Commit 07412a6

Browse files
ADR-008: endpoint parameter convention (Proposed)
Captures the request-object vs. params-bag vs. fluent-terminal decision that the options resource introduces SDK-wide. Leads with the cross-SDK comparison (sdk-py / sdk-js bags vs. the four Java candidates) and recommends the inert request object plus an additive Consumer<Builder> overload, preserving the compile-time sealed-filter guarantee.
1 parent 63ac16d commit 07412a6

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# ADR-008: Endpoint Parameter Convention (Request Objects)
2+
3+
## Status
4+
5+
Proposed.
6+
7+
## Context
8+
9+
ADR-006 fixed *how many* surfaces each endpoint exposes (sync + async
10+
parity). It did **not** fix *how an endpoint accepts its parameters*
11+
the call shape a consumer actually types. The `options` resource (the
12+
second resource after `utilities`, and the template the remaining
13+
`stocks` / `funds` / `markets` resources will copy) is the first place
14+
this decision becomes load-bearing, because `chain` carries the richest
15+
filter surface in the API (~25 query parameters, plus two groups of
16+
mutually-exclusive filters).
17+
18+
The cross-language sibling SDKs have already answered this question, and
19+
they answered it the same way:
20+
21+
```python
22+
# sdk-py — kwargs bag
23+
client.options.chain("AAPL", side="call", strike_limit=10, min_open_interest=100)
24+
```
25+
26+
```typescript
27+
// sdk-js — options-object bag
28+
client.options.chain("AAPL", { side: "call", strikeLimit: 10, minOpenInterest: 100 });
29+
```
30+
31+
Both use *positional required argument + one flat optional bag*. Both
32+
validate at runtime (Pydantic / Zod). Neither models the
33+
mutually-exclusive filter groups — in both, you can pass `dte` **and**
34+
`expiration` together and the backend arbitrates. `sdk-js` even allows
35+
unknown keys through (`.passthrough()`).
36+
37+
Java cannot copy that shape directly: it has no keyword arguments and no
38+
object literals. The idiomatic Java substitutes are a request object
39+
with a builder, a `Consumer<Builder>` lambda, a transport-bound fluent
40+
builder, or a flat "parameters" object mirroring the siblings. The
41+
choice matters because:
42+
43+
- It is a **public-API decision**, hard to reverse without breaking
44+
changes, and it will be **replicated across every future resource**
45+
so it should be decided once, here, not endpoint-by-endpoint.
46+
- Java is the **only** SDK in the family whose type system can make the
47+
mutually-exclusive filter groups *unrepresentable* rather than merely
48+
*validated*. `chain` models them as sealed types (`ExpirationFilter`
49+
`OnDate` / `Dte` / `Between` / `MonthYear` / `All`; `StrikeFilter`
50+
`Exact` / `Range` / `Comparison`) reached through a single setter, so
51+
"pick one variant" is enforced by `javac`. Whatever convention we pick
52+
must preserve that, because it is the one dimension where the Java SDK
53+
is strictly safer than its siblings.
54+
- ADR-006 parity and the **deferred §3 universal parameters**
55+
(`format`, `dateformat`, `columns`, `mode`, …, landing with `stocks`)
56+
both interact with the convention: a request object adds universal
57+
params as a second overload; a fluent terminal folds them in as more
58+
setters.
59+
60+
Filter set held constant across the options below: **calls only, strike
61+
limit 10, minimum open interest 100.**
62+
63+
## Options Considered
64+
65+
### Option A — Request object + builder (the PR as written)
66+
67+
Each endpoint takes one immutable request object, constructed via a
68+
static `builder(required…)` (or `of(required…)` when there are no
69+
optionals). One signature per endpoint, both surfaces.
70+
71+
```java
72+
client.options().chain(
73+
OptionsChainRequest.builder("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).build());
74+
75+
client.options().chain(OptionsChainRequest.of("AAPL")); // no filters
76+
```
77+
78+
**Pros**
79+
80+
- The request object is **inert and decoupled** — no client reference.
81+
Reusable across calls and clients, inspectable, loggable, and
82+
unit-testable (assert the query translation without a transport).
83+
- One object feeds both `chain(req)` and `chainAsync(req)` with **zero
84+
duplicated parameter surface** — ADR-006 parity is trivial.
85+
- **Uniform call shape** across all six endpoints regardless of
86+
parameter richness; the future universal-params overload retrofits
87+
cleanly as `chain(req, universalParams)`.
88+
- Immutable, matching the records/immutability ethos (ADR-005/007).
89+
- Sealed filters live naturally on the builder.
90+
91+
**Cons**
92+
93+
- `.build()` ceremony on every inline call.
94+
- Naming stutter: `options().chain(…)` then `OptionsChainRequest`.
95+
- Loses to a kwargs bag / object literal on raw terseness — Java will
96+
never win that contest.
97+
98+
### Option B — Option A plus a `Consumer<Builder>` overload
99+
100+
Keep everything in Option A; add an additive overload that hides the
101+
builder naming and `.build()` for the inline case.
102+
103+
```java
104+
client.options().chain("AAPL", b -> b.side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100));
105+
client.options().chain("AAPL"); // bare overload for the no-filter case
106+
```
107+
108+
**Pros**
109+
110+
- Closest spiritual match to how `sdk-py` / `sdk-js` actually solved it
111+
(required positional + optional configuration), so cross-SDK muscle
112+
memory transfers.
113+
- Keeps **all** of Option A's properties — the request object stays
114+
inert and decoupled; `chain(req)` survives for reuse and for
115+
conditional/dynamic filter construction (which reads badly inside a
116+
lambda).
117+
- Purely additive over Option A: ~6 lines per endpoint delegating to the
118+
existing request-object method; no change to the request classes.
119+
120+
**Cons**
121+
122+
- Reopens the explicit "no `String` convenience overloads, uniform call
123+
shape" decision the current PR made on purpose.
124+
- Overload count: `chain(req)` + `chain(String, Consumer)` +
125+
`chain(String)` = 3 signatures × sync/async = 6 per endpoint, and the
126+
deferred universal-params overload can multiply that again unless
127+
universal params fold into the builder.
128+
- Per call site you can avoid the lambda **or** the builder naming, but
129+
not both — the lambda exists precisely to hide the builder, so the
130+
lambda-free form (`chain(req)`) necessarily names `builder()`/`build()`.
131+
132+
### Option C — Transport-bound fluent builder + terminal verb
133+
134+
`chain("AAPL")` returns a builder wired to the transport; filters are
135+
fluent setters; a mandatory terminal (`fetch()` / `fetchAsync()`)
136+
executes. This is the only shape that drops the lambda **and** the
137+
builder naming **and** `.build()` simultaneously.
138+
139+
```java
140+
client.options().chain("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).fetch();
141+
```
142+
143+
**Pros**
144+
145+
- Shortest possible call site; closest to the siblings' brevity.
146+
- Universal params fold in as more setters before the terminal — **no
147+
overload growth ever**.
148+
- Uniform if applied to all six endpoints.
149+
150+
**Cons**
151+
152+
- The builder is **fused to the transport** — it *is* a live request,
153+
not data. Loses the free-standing, reusable, unit-testable spec unless
154+
a parallel inert `OptionsChainRequest` + `chain(req)` surface is kept
155+
too (two ways to do everything).
156+
- **Dangling-terminal footgun:** `chain("AAPL").side(CALL);` (no
157+
terminal) compiles and silently does nothing. Java's type system
158+
cannot force termination; only an ErrorProne `@CheckReturnValue`
159+
backstop catches it.
160+
- Moves the ADR-006 sync/async pair off the resource method onto the
161+
terminal verb — an **ADR-006 amendment**, not a free refactor.
162+
- Makes Java the **call-shape outlier** among the three SDKs (neither
163+
sibling has a terminal verb).
164+
165+
### Option D — Flat parameters object (mirror the siblings)
166+
167+
A single `OptionsChainParams` object (or a long overloaded signature)
168+
with every filter as an independent optional field, mirroring the
169+
Python/JS bag exactly — no sealed types.
170+
171+
```java
172+
var p = new OptionsChainParams();
173+
p.setSide(OptionSide.CALL); p.setStrikeLimit(10); p.setMinOpenInterest(100);
174+
client.options().chain("AAPL", p);
175+
```
176+
177+
**Pros**
178+
179+
- Maximum cross-SDK structural consistency.
180+
- Familiar to consumers coming from `sdk-py` / `sdk-js`.
181+
182+
**Cons**
183+
184+
- **Throws away the one Java advantage:** mutually-exclusive filters
185+
become independent fields again, so `dte` + `expiration` is back to a
186+
runtime/server error instead of a compile error. Re-implements the
187+
siblings' weakest property in the one language that doesn't have to.
188+
- Mutable params object cuts against the immutability ethos.
189+
- Validation regresses from "unrepresentable" to "checked in `build()`/
190+
on the wire."
191+
192+
## Claude's Recommendation
193+
194+
**Option A as the canonical, ADR-blessed form, plus Option B's
195+
`Consumer<Builder>` overload as the ergonomic front door.**
196+
197+
The deciding factors:
198+
199+
1. Terseness is unwinnable for Java against an object literal or kwargs —
200+
so it should not be the optimization target. The Java SDK's
201+
differentiator is the **compile-time sealed filters** (Options A/B/C
202+
keep them; Option D discards them). Protect that; it is the only
203+
column where Java leads its siblings.
204+
2. The **inert, decoupled request object** (A/B) buys reuse,
205+
testability, trivial ADR-006 parity, and freedom from the
206+
dangling-terminal footgun. Option C trades all of that for a cosmetic
207+
win and forces an ADR-006 amendment plus cross-SDK divergence.
208+
3. Option B's overload is **purely additive** and recovers the siblings'
209+
call ergonomics (`chain("AAPL", b -> …)` / `chain("AAPL")`) without
210+
sacrificing anything in A. Its only real cost — overload count — is a
211+
conscious, bounded trade, not the open-ended growth Option C's
212+
detractors and the original PR feared.
213+
4. Adopting B is an **all-or-nothing convention**: if `chain` gets the
214+
overload, every endpoint should, so the SDK has one front door, not a
215+
per-endpoint coin flip. That uniformity is exactly what an ADR is for.
216+
217+
The strongest counter-recommendation is **Option A alone** (the PR as
218+
written): one signature per endpoint is the simplest possible surface
219+
and the cleanest universal-params retrofit. The case against it is
220+
purely ergonomic — `OptionsChainRequest.builder(…).build()` at every
221+
call site — and Option B answers that without giving anything up. If the
222+
team values minimal surface over call-site ergonomics, shipping A in the
223+
options PR and adding B in a follow-up is a reasonable middle path (the
224+
overload is additive and non-breaking, so deferring it costs nothing
225+
structurally).
226+
227+
## Decision
228+
229+
*Pending team ratification (status: Proposed).*
230+
231+
Recommended: **Option A + Option B.** Each endpoint keeps a single
232+
immutable request object (`builder(required…)` / `of(required…)`) as the
233+
canonical form feeding both sync and async surfaces, and additionally
234+
exposes a `foo(String required, Consumer<FooRequest.Builder>)` overload
235+
(plus a bare `foo(String required)` for the no-optional case) as the
236+
ergonomic front door. The sealed mutually-exclusive filter groups
237+
(`ExpirationFilter`, `StrikeFilter`) are retained unchanged. Universal
238+
parameters (§3, deferred to `stocks`) retrofit as a second request-object
239+
overload, not as additional builder state.
240+
241+
Options C (transport-bound fluent terminal) and D (flat params object)
242+
were considered and are not recommended — C because it sacrifices the
243+
decoupled request object, introduces an un-enforceable
244+
dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK
245+
call-shape outlier; D because it discards the compile-time
246+
mutual-exclusivity guarantee that is the Java SDK's one advantage over
247+
its siblings.
248+
249+
## Consequences
250+
251+
Follow-on work implied by each option. The recommended option is marked.
252+
253+
- **A (request object only):** One signature per endpoint. Call sites
254+
always name `FooRequest.builder(…).build()` (or `of(…)`). No lambda
255+
overloads. The convention already shipped in the `options` PR.
256+
- **A + B (recommended):** Every endpoint additionally gets
257+
`foo(String, Consumer<Builder>)` and `foo(String)` overloads
258+
delegating to `foo(FooRequest)`. Applied uniformly across `options`
259+
and every future resource. Docs name `foo(FooRequest)` as canonical
260+
(reuse / conditional construction) and the lambda overload as the
261+
quick path. Each new endpoint adds ~6 lines of delegating overloads.
262+
- **C (fluent terminal):** All six endpoints return transport-bound
263+
builders with `fetch()` / `fetchAsync()` terminals; ADR-006 amended so
264+
the terminal pair is the documented endpoint surface; ErrorProne
265+
`@CheckReturnValue` added on builder setters to mitigate the
266+
dangling-terminal footgun; the inert request object is dropped or
267+
maintained as a second surface.
268+
- **D (flat params object):** Sealed `ExpirationFilter` / `StrikeFilter`
269+
collapse into independent optional fields on a mutable params object;
270+
mutual-exclusivity moves to `build()`/wire-time validation.
271+
272+
## References
273+
274+
- [Market Data SDK Requirements §3 Universal Parameters, §Language-Idiomatic Design](../sdk-requirements.md)
275+
- [Java SDK Requirements §2 — Kotlin interop](../java-sdk-requirements.md)
276+
- [ADR-005 — JSON Library](./ADR-005-json-library.md) — records / immutability ethos
277+
- [ADR-006 — Async API Surface](./ADR-006-async-api-surface.md) — sync/async parity that the terminal-verb option (C) would amend
278+
- [ADR-007 — Internal API Encapsulation](./ADR-007-internal-api-encapsulation.md) — package-private constructors on resource façades and request types
279+
- Sibling SDKs (not committed in this repo): `sdk-py` `client.options.chain(...)` (kwargs bag); `sdk-js` `client.options.chain(...)` (options-object bag, Zod `.passthrough()`)

0 commit comments

Comments
 (0)