|
| 1 | +# ADR-007: Encapsulation of Internal Types on the Public API Surface |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed. |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Resource façades like `MarketsResource` are part of the public API |
| 10 | +(`com.marketdata.sdk.markets`) — consumers reach them via |
| 11 | +`client.markets()`. They are also constructed by |
| 12 | +`MarketDataClient`, which lives in a *different* package |
| 13 | +(`com.marketdata.sdk`). Java's visibility levels are `private`, |
| 14 | +package-private, `protected`, and `public`; there is no |
| 15 | +"module-internal cross-package" level in the language base. |
| 16 | +Cross-package construction therefore requires the constructor to be |
| 17 | +`public`, even when conceptually "this should not be called from |
| 18 | +outside the SDK". |
| 19 | + |
| 20 | +The current `MarketsResource(HttpTransport transport)` constructor |
| 21 | +illustrates the problem: |
| 22 | + |
| 23 | +```java |
| 24 | +// src/main/java/com/marketdata/sdk/markets/MarketsResource.java |
| 25 | +/** |
| 26 | + * Package-private: only {@link com.marketdata.sdk.MarketDataClient} constructs |
| 27 | + * resources, so consumers get one via {@code client.markets()}. |
| 28 | + */ |
| 29 | +public MarketsResource(HttpTransport transport) { // ← Javadoc lies; ctor is public |
| 30 | + this.transport = transport; |
| 31 | +} |
| 32 | +``` |
| 33 | + |
| 34 | +`HttpTransport` lives in `com.marketdata.sdk.internal.http`. The |
| 35 | +package is named `internal` *by convention* — the language gives no |
| 36 | +enforcement. Direct consequences: |
| 37 | + |
| 38 | +- A consumer can write |
| 39 | + `new MarketsResource(new HttpTransport("https://...", "v1", "ua", "token"))` |
| 40 | + and bypass `MarketDataClient` and everything it enforces (configuration |
| 41 | + cascade per §4, demo mode, future startup validation, retry |
| 42 | + policy, telemetry). |
| 43 | +- Generated Javadoc lists the constructor as part of the public API, |
| 44 | + blurring the contract. |
| 45 | +- Any change to `HttpTransport`'s constructor signature is a |
| 46 | + technically SemVer-breaking change because that signature appears |
| 47 | + in a public-API position — even though the type *lives in* a package |
| 48 | + named `internal`. SemVer-strict tooling (`revapi`, `japicmp`) will |
| 49 | + flag it. |
| 50 | + |
| 51 | +The leak is the same kind every non-modular Java SDK has (Stripe Java, |
| 52 | +AWS SDK v1, Twilio). It is moderate severity rather than critical, but |
| 53 | +it directly contradicts the Kotlin-interop and clean-API goals of |
| 54 | +ADRs 001 and 006. |
| 55 | + |
| 56 | +A related observation: the same problem appears for *every* resource |
| 57 | +that lands in subsequent endpoint work (`stocks`, `options`, `funds`, |
| 58 | +`utilities`). Whatever we choose, it must scale to roughly five resource |
| 59 | +façades by v1. |
| 60 | + |
| 61 | +## Options Considered |
| 62 | + |
| 63 | +### Option A — JPMS (`module-info.java`) |
| 64 | + |
| 65 | +Add a Java module descriptor at `src/main/java/module-info.java` that |
| 66 | +exports only the public-API packages and leaves `internal.*` unexported. |
| 67 | +Consumers using the modulepath will find `HttpTransport` literally |
| 68 | +unreachable: the import line will fail to compile. |
| 69 | + |
| 70 | +```java |
| 71 | +module com.marketdata.sdk { |
| 72 | + requires java.net.http; |
| 73 | + requires com.fasterxml.jackson.databind; |
| 74 | + requires static org.jspecify; // compile-time only |
| 75 | + |
| 76 | + exports com.marketdata.sdk; |
| 77 | + exports com.marketdata.sdk.markets; |
| 78 | + exports com.marketdata.sdk.exception; |
| 79 | + // com.marketdata.sdk.internal.* — intentionally NOT exported |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +**Pros** |
| 84 | + |
| 85 | +- Zero refactor of existing source. The current package layout is |
| 86 | + preserved verbatim — `internal/http`, `internal/wire/markets`, the |
| 87 | + whole tree stays where it is. |
| 88 | +- Compile-time enforcement at the *language* level for modulepath |
| 89 | + consumers: the consumer's compiler refuses |
| 90 | + `import com.marketdata.sdk.internal.http.HttpTransport`. |
| 91 | +- IDEs surface the unexported package as a warning or error even when |
| 92 | + the consumer is on classpath. |
| 93 | +- SemVer-strict tooling (`revapi`, `japicmp`) reads `module-info` and |
| 94 | + knows precisely what the API surface is; spurious "breaking |
| 95 | + changes" on `internal.*` types disappear. |
| 96 | +- This is what AWS SDK v2, Spring Boot (modular), Hibernate ORM |
| 97 | + (modular) do. |
| 98 | + |
| 99 | +**Cons** |
| 100 | + |
| 101 | +- Enforcement is full only on **modulepath** consumers. Classpath |
| 102 | + consumers see `internal.*` as part of the unnamed module and can |
| 103 | + still reference it (with IDE warnings). |
| 104 | +- Adds module-system reasoning to the build (transitive `requires`, |
| 105 | + test patching). Gradle 9 + `java-library` handles this transparently |
| 106 | + for our case, but it is one more thing to know. |
| 107 | +- Reflection still works the same way as today: callers using |
| 108 | + `setAccessible(true)` can reach private members. To block that, the |
| 109 | + consumer would need to be denied `--add-opens` for our packages — |
| 110 | + which is the JVM default for non-exported packages. |
| 111 | + |
| 112 | +#### End-user impact (special focus per the request) |
| 113 | + |
| 114 | +How JPMS affects each consumer cohort: |
| 115 | + |
| 116 | +- **Production consumer using Maven/Gradle classpath (most common today):** |
| 117 | + Library appears on the classpath, the JVM treats it as part of the |
| 118 | + unnamed module, and `exports` directives are *not enforced at runtime*. |
| 119 | + Such a consumer can still write |
| 120 | + `import com.marketdata.sdk.internal.http.HttpTransport;`. **However**, |
| 121 | + IntelliJ, Eclipse, and recent VS Code Java extensions display a |
| 122 | + visible warning ("module com.marketdata.sdk does not export |
| 123 | + com.marketdata.sdk.internal.http") — strong social signal, no hard |
| 124 | + block. |
| 125 | + |
| 126 | +- **Production consumer using modulepath (`requires com.marketdata.sdk;` |
| 127 | + in their own `module-info.java`):** |
| 128 | + The import simply does not compile. The consumer's build fails with |
| 129 | + "package com.marketdata.sdk.internal.http is declared in module |
| 130 | + com.marketdata.sdk, which does not export it." This is the cohort the |
| 131 | + ADR primarily protects. |
| 132 | + |
| 133 | +- **Consumer running tests against the SDK:** |
| 134 | + Their test code, when it lives in their own module, is governed by |
| 135 | + the same rules as their production code. Tests in our SDK's own |
| 136 | + module continue to access internals via the test source set |
| 137 | + (Gradle handles `--patch-module` for test classpaths automatically). |
| 138 | + |
| 139 | +- **Reflective consumer (advanced):** |
| 140 | + Default behaviour for non-exported packages is "deep reflection |
| 141 | + blocked." The consumer must add `--add-opens |
| 142 | + com.marketdata.sdk/com.marketdata.sdk.internal.http=ALL-UNNAMED` to |
| 143 | + the JVM args of their application — a deliberate choice that is now |
| 144 | + visible at deploy time, not buried in code. |
| 145 | + |
| 146 | +- **Consumer of the published Javadoc:** |
| 147 | + Standard Javadoc for a modular JAR omits the unexported packages. |
| 148 | + `com.marketdata.sdk.internal.*` no longer appears in the rendered |
| 149 | + docs, eliminating the "Javadoc lists the constructor as public API" |
| 150 | + symptom. |
| 151 | + |
| 152 | +- **Consumer running a SemVer-comparison tool (`revapi`, `japicmp`):** |
| 153 | + Tools that read `module-info` correctly classify `internal.*` as |
| 154 | + non-API. Changes to `HttpTransport`'s signature stop registering as |
| 155 | + breaking — they were never API. |
| 156 | + |
| 157 | +- **Migration cost on the consumer side:** |
| 158 | + Zero for classpath consumers (they keep working as before). Modulepath |
| 159 | + consumers add `requires com.marketdata.sdk;` to their `module-info` — |
| 160 | + a one-line change, standard for any modular dependency. |
| 161 | + |
| 162 | +The asterisk to be honest about: a determined classpath consumer can |
| 163 | +*technically* still use `internal.*` types — but they get IDE |
| 164 | +warnings, lose Javadoc visibility, and accept that any future change |
| 165 | +to those types is by definition not a SemVer break (we can change them |
| 166 | +freely). The contract is now legible. |
| 167 | + |
| 168 | +### Option B — Single-package infra (collapse the directory tree) |
| 169 | + |
| 170 | +Move `MarketsResource` (and every future resource façade), `HttpTransport`, |
| 171 | +`AsyncSemaphore`, `RequestSpec`, `HttpStatusMapper`, `RateLimitHeaders`, |
| 172 | +`Configuration`, `EnvVars`, `Tokens`, `Version`, plus every wire-format |
| 173 | +deserializer — all into the same package as `MarketDataClient` |
| 174 | +(`com.marketdata.sdk`). Drop the `public` modifier from every |
| 175 | +"internal" class so it becomes package-private. The compiler then |
| 176 | +refuses to compile a consumer's reference to those types because the |
| 177 | +type itself is not visible. |
| 178 | + |
| 179 | +``` |
| 180 | +com.marketdata.sdk/ |
| 181 | +├── MarketDataClient.java public |
| 182 | +├── RateLimits.java public |
| 183 | +├── MarketsResource.java public final, ctor package-private |
| 184 | +├── HttpTransport.java package-private |
| 185 | +├── AsyncSemaphore.java package-private |
| 186 | +├── RequestSpec.java package-private |
| 187 | +├── HttpStatusMapper.java package-private |
| 188 | +├── RateLimitHeaders.java package-private |
| 189 | +├── Configuration.java package-private |
| 190 | +├── MarketStatusDeserializer.java package-private |
| 191 | +├── SdkJsonModule.java package-private |
| 192 | +└── ... ~25 files in a single root package |
| 193 | +
|
| 194 | +com.marketdata.sdk.markets/ ← only public response DTOs |
| 195 | +├── MarketStatus.java |
| 196 | +└── DailyStatus.java |
| 197 | +
|
| 198 | +com.marketdata.sdk.exception/ ← only public exceptions |
| 199 | +└── ... |
| 200 | +``` |
| 201 | + |
| 202 | +The consumer cannot import a type that is not `public`, so: |
| 203 | + |
| 204 | +```java |
| 205 | +// Does NOT compile: |
| 206 | +import com.marketdata.sdk.HttpTransport; // package-private |
| 207 | +new MarketsResource(/*...*/); // package-private ctor |
| 208 | +``` |
| 209 | + |
| 210 | +**Pros** |
| 211 | + |
| 212 | +- Pure Java visibility. No JPMS, no module reasoning, no tooling |
| 213 | + asterisk. Compile-time enforcement everywhere — classpath and |
| 214 | + modulepath alike. |
| 215 | +- Reflection still gated by `setAccessible(true)` + `--add-opens` in |
| 216 | + modular runtimes; for non-modular runtimes the bar is the same as |
| 217 | + the rest of the JDK. |
| 218 | +- Industry precedent: Stripe Java's pre-modular layout uses a similar |
| 219 | + structure (one root package, response DTOs in subpackages). |
| 220 | + |
| 221 | +**Cons** |
| 222 | + |
| 223 | +- Refactor scope is real: ~25 files change package + the `internal/` |
| 224 | + subdirectory disappears as a visual cue (replaced by the |
| 225 | + `package-private` modifier as the "do not touch" signal). |
| 226 | +- The `@JsonDeserialize(using = MarketStatusDeserializer.class)` |
| 227 | + annotation on response records can no longer reference a |
| 228 | + package-private class from another package. Deserializers must move |
| 229 | + to programmatic registration via a `SimpleModule` on |
| 230 | + `HttpTransport`'s `ObjectMapper`. This is a real architectural change |
| 231 | + on top of the move (decoupling response models from their wire-format |
| 232 | + logic — arguably an improvement, but additional work). |
| 233 | +- Test sources for any package-private type must live in the matching |
| 234 | + test package, which already happens — but every test that previously |
| 235 | + imported `com.marketdata.sdk.internal.http.HttpTransport` now imports |
| 236 | + `com.marketdata.sdk.HttpTransport`. Mechanical, not zero. |
| 237 | +- Naming feels slightly off: `com.marketdata.sdk.MarketsResource` instead |
| 238 | + of `com.marketdata.sdk.markets.MarketsResource`. Consumers rarely |
| 239 | + import the resource class by name (they go through |
| 240 | + `client.markets()`), but it is a smell at first glance. |
| 241 | +- A single package with ~25 classes is fine technically but |
| 242 | + organisationally noisier than the current layered tree. |
| 243 | + |
| 244 | +## Claude's Recommendation |
| 245 | + |
| 246 | +**Option A (JPMS), with Option B as a follow-on if and when the team |
| 247 | +decides classpath enforcement is required.** |
| 248 | + |
| 249 | +Reasoning: |
| 250 | + |
| 251 | +1. JPMS is the **lowest-risk** option that produces real enforcement |
| 252 | + for the most relevant consumer cohorts. ~1 file added (`module-info.java`), |
| 253 | + no source moves, no API renames, no annotation refactor. |
| 254 | +2. Modulepath adoption among Java consumers is increasing every year. |
| 255 | + AWS SDK v2 and Spring Boot have already crossed the bar; Java |
| 256 | + consumers building anything modular today already write |
| 257 | + `module-info` files. By the time this SDK has been in the field for |
| 258 | + v1.x lifetime, modulepath enforcement will cover the majority of |
| 259 | + serious consumers. |
| 260 | +3. The classpath asterisk is real but mitigated by IDE warnings and |
| 261 | + SemVer tooling that already reads `module-info` correctly. The |
| 262 | + consumer who *deliberately* imports `internal.*` from classpath has |
| 263 | + accepted a one-sided contract — we can change those types freely. |
| 264 | +4. Option B genuinely closes the gap for classpath consumers but at |
| 265 | + substantial refactor cost (~25 file moves + deserializer |
| 266 | + registration refactor). Worthwhile only if the threat model is |
| 267 | + "adversarial classpath consumer". For "respectful classpath consumer |
| 268 | + + adversarial modulepath consumer", Option A is sufficient and B is |
| 269 | + over-engineering. |
| 270 | + |
| 271 | +The strongest counter-argument to Option A is "it is not enforcement, |
| 272 | +only a strong signal, for classpath consumers." That is correct. The |
| 273 | +counter-counter-argument is "every Java SDK in our space has the same |
| 274 | +property; the alternative (Option B) costs more than the gap is |
| 275 | +worth." If the team disagrees on the threat model, Option B is the |
| 276 | +escape hatch — but it should be a deliberate choice, not a default. |
| 277 | + |
| 278 | +## Decision |
| 279 | + |
| 280 | +*Pending team review.* |
| 281 | + |
| 282 | +## Consequences |
| 283 | + |
| 284 | +Follow-on work implied by each option. The chosen option will be |
| 285 | +marked when the decision is made. |
| 286 | + |
| 287 | +- **A (JPMS, recommended):** Add `src/main/java/module-info.java` with |
| 288 | + the exports listed in the option above. Add `requires` clauses for |
| 289 | + Jackson and JSpecify (`requires static org.jspecify` for compile-time |
| 290 | + only). Update CLAUDE.md to record the modulepath/classpath |
| 291 | + enforcement contract. No source moves, no refactor of resource code. |
| 292 | + Tests continue to access internals via Gradle's standard test source |
| 293 | + set wiring. Verify that `./gradlew build` still succeeds end-to-end |
| 294 | + and that `javadoc` no longer surfaces `internal.*` packages. |
| 295 | + |
| 296 | +- **B (single-package infra):** Move every infra and resource façade |
| 297 | + class to `com.marketdata.sdk`; drop `public` from every type that |
| 298 | + was internal. Refactor `@JsonDeserialize` annotations off response |
| 299 | + records and into a programmatic `SimpleModule` registered on |
| 300 | + `HttpTransport`'s `ObjectMapper`. Update every test that imports |
| 301 | + formerly-internal types to the new package. Update CLAUDE.md to |
| 302 | + describe the single-package convention and the package-private |
| 303 | + default for non-DTO classes. |
| 304 | + |
| 305 | +## References |
| 306 | + |
| 307 | +- [Java Language Specification §7 — Packages](https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html) |
| 308 | +- [JEP 261: Module System (JPMS)](https://openjdk.org/jeps/261) |
| 309 | +- [State of the Module System — Mark Reinhold](https://openjdk.org/projects/jigsaw/spec/sotms/) |
| 310 | +- [Stripe Java SDK — package layout](https://github.com/stripe/stripe-java) |
| 311 | +- [AWS SDK for Java v2 — modular layout](https://github.com/aws/aws-sdk-java-v2) |
| 312 | +- [`revapi` — API change tracking with module-info awareness](https://revapi.org/) |
| 313 | +- [ADR-001 — Java Only vs. Multi-Language SDK](./ADR-001-java-only-vs-multi-language-sdk.md) |
| 314 | +- [ADR-002 — Minimum JDK Version](./ADR-002-minimum-jdk-version.md) |
0 commit comments