|
| 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