Accepted.
Resource façades like MarketsResource are part of the public API
(com.marketdata.sdk.markets) — consumers reach them via
client.markets(). They are also constructed by
MarketDataClient, which lives in a different package
(com.marketdata.sdk). Java's visibility levels are private,
package-private, protected, and public; there is no
"module-internal cross-package" level in the language base.
Cross-package construction therefore requires the constructor to be
public, even when conceptually "this should not be called from
outside the SDK".
The current MarketsResource(HttpTransport transport) constructor
illustrates the problem:
// src/main/java/com/marketdata/sdk/markets/MarketsResource.java
/**
* Package-private: only {@link com.marketdata.sdk.MarketDataClient} constructs
* resources, so consumers get one via {@code client.markets()}.
*/
public MarketsResource(HttpTransport transport) { // ← Javadoc lies; ctor is public
this.transport = transport;
}HttpTransport lives in com.marketdata.sdk.internal.http. The
package is named internal by convention — the language gives no
enforcement. Direct consequences:
- A consumer can write
new MarketsResource(new HttpTransport("https://...", "v1", "ua", "token"))and bypassMarketDataClientand everything it enforces (configuration cascade per §4, demo mode, future startup validation, retry policy, telemetry). - Generated Javadoc lists the constructor as part of the public API, blurring the contract.
- Any change to
HttpTransport's constructor signature is a technically SemVer-breaking change because that signature appears in a public-API position — even though the type lives in a package namedinternal. SemVer-strict tooling (revapi,japicmp) will flag it.
The leak is the same kind every non-modular Java SDK has (Stripe Java, AWS SDK v1, Twilio). It is moderate severity rather than critical, but it directly contradicts the Kotlin-interop and clean-API goals of ADRs 001 and 006.
A related observation: the same problem appears for every resource
that lands in subsequent endpoint work (stocks, options, funds,
utilities). Whatever we choose, it must scale to roughly five resource
façades by v1.
Add a Java module descriptor at src/main/java/module-info.java that
exports only the public-API packages and leaves internal.* unexported.
Consumers using the modulepath will find HttpTransport literally
unreachable: the import line will fail to compile.
module com.marketdata.sdk {
requires java.net.http;
requires com.fasterxml.jackson.databind;
requires static org.jspecify; // compile-time only
exports com.marketdata.sdk;
exports com.marketdata.sdk.markets;
exports com.marketdata.sdk.exception;
// com.marketdata.sdk.internal.* — intentionally NOT exported
}Pros
- Zero refactor of existing source. The current package layout is
preserved verbatim —
internal/http,internal/wire/markets, the whole tree stays where it is. - Compile-time enforcement at the language level for modulepath
consumers: the consumer's compiler refuses
import com.marketdata.sdk.internal.http.HttpTransport. - IDEs surface the unexported package as a warning or error even when the consumer is on classpath.
- SemVer-strict tooling (
revapi,japicmp) readsmodule-infoand knows precisely what the API surface is; spurious "breaking changes" oninternal.*types disappear. - This is what AWS SDK v2, Spring Boot (modular), Hibernate ORM (modular) do.
Cons
- Enforcement is full only on modulepath consumers. Classpath
consumers see
internal.*as part of the unnamed module and can still reference it (with IDE warnings). - Adds module-system reasoning to the build (transitive
requires, test patching). Gradle 9 +java-libraryhandles this transparently for our case, but it is one more thing to know. - Reflection still works the same way as today: callers using
setAccessible(true)can reach private members. To block that, the consumer would need to be denied--add-opensfor our packages — which is the JVM default for non-exported packages.
How JPMS affects each consumer cohort:
-
Production consumer using Maven/Gradle classpath (most common today): Library appears on the classpath, the JVM treats it as part of the unnamed module, and
exportsdirectives are not enforced at runtime. Such a consumer can still writeimport com.marketdata.sdk.internal.http.HttpTransport;. However, IntelliJ, Eclipse, and recent VS Code Java extensions display a visible warning ("module com.marketdata.sdk does not export com.marketdata.sdk.internal.http") — strong social signal, no hard block. -
Production consumer using modulepath (
requires com.marketdata.sdk;in their ownmodule-info.java): The import simply does not compile. The consumer's build fails with "package com.marketdata.sdk.internal.http is declared in module com.marketdata.sdk, which does not export it." This is the cohort the ADR primarily protects. -
Consumer running tests against the SDK: Their test code, when it lives in their own module, is governed by the same rules as their production code. Tests in our SDK's own module continue to access internals via the test source set (Gradle handles
--patch-modulefor test classpaths automatically). -
Reflective consumer (advanced): Default behaviour for non-exported packages is "deep reflection blocked." The consumer must add
--add-opens com.marketdata.sdk/com.marketdata.sdk.internal.http=ALL-UNNAMEDto the JVM args of their application — a deliberate choice that is now visible at deploy time, not buried in code. -
Consumer of the published Javadoc: Standard Javadoc for a modular JAR omits the unexported packages.
com.marketdata.sdk.internal.*no longer appears in the rendered docs, eliminating the "Javadoc lists the constructor as public API" symptom. -
Consumer running a SemVer-comparison tool (
revapi,japicmp): Tools that readmodule-infocorrectly classifyinternal.*as non-API. Changes toHttpTransport's signature stop registering as breaking — they were never API. -
Migration cost on the consumer side: Zero for classpath consumers (they keep working as before). Modulepath consumers add
requires com.marketdata.sdk;to theirmodule-info— a one-line change, standard for any modular dependency.
The asterisk to be honest about: a determined classpath consumer can
technically still use internal.* types — but they get IDE
warnings, lose Javadoc visibility, and accept that any future change
to those types is by definition not a SemVer break (we can change them
freely). The contract is now legible.
Move MarketsResource (and every future resource façade), HttpTransport,
AsyncSemaphore, RequestSpec, HttpStatusMapper, RateLimitHeaders,
Configuration, EnvVars, Tokens, Version, plus every wire-format
deserializer — all into the same package as MarketDataClient
(com.marketdata.sdk). Drop the public modifier from every
"internal" class so it becomes package-private. The compiler then
refuses to compile a consumer's reference to those types because the
type itself is not visible.
com.marketdata.sdk/
├── MarketDataClient.java public
├── RateLimits.java public
├── MarketsResource.java public final, ctor package-private
├── HttpTransport.java package-private
├── AsyncSemaphore.java package-private
├── RequestSpec.java package-private
├── HttpStatusMapper.java package-private
├── RateLimitHeaders.java package-private
├── Configuration.java package-private
├── MarketStatusDeserializer.java package-private
├── SdkJsonModule.java package-private
└── ... ~25 files in a single root package
com.marketdata.sdk.markets/ ← only public response DTOs
├── MarketStatus.java
└── DailyStatus.java
com.marketdata.sdk.exception/ ← only public exceptions
└── ...
The consumer cannot import a type that is not public, so:
// Does NOT compile:
import com.marketdata.sdk.HttpTransport; // package-private
new MarketsResource(/*...*/); // package-private ctorPros
- Pure Java visibility. No JPMS, no module reasoning, no tooling asterisk. Compile-time enforcement everywhere — classpath and modulepath alike.
- Reflection still gated by
setAccessible(true)+--add-opensin modular runtimes; for non-modular runtimes the bar is the same as the rest of the JDK. - Industry precedent: Stripe Java's pre-modular layout uses a similar structure (one root package, response DTOs in subpackages).
Cons
- Refactor scope is real: ~25 files change package + the
internal/subdirectory disappears as a visual cue (replaced by thepackage-privatemodifier as the "do not touch" signal). - The
@JsonDeserialize(using = MarketStatusDeserializer.class)annotation on response records can no longer reference a package-private class from another package. Deserializers must move to programmatic registration via aSimpleModuleonHttpTransport'sObjectMapper. This is a real architectural change on top of the move (decoupling response models from their wire-format logic — arguably an improvement, but additional work). - Test sources for any package-private type must live in the matching
test package, which already happens — but every test that previously
imported
com.marketdata.sdk.internal.http.HttpTransportnow importscom.marketdata.sdk.HttpTransport. Mechanical, not zero. - Naming feels slightly off:
com.marketdata.sdk.MarketsResourceinstead ofcom.marketdata.sdk.markets.MarketsResource. Consumers rarely import the resource class by name (they go throughclient.markets()), but it is a smell at first glance. - A single package with ~25 classes is fine technically but organisationally noisier than the current layered tree.
Option A (JPMS), with Option B as a follow-on if and when the team decides classpath enforcement is required.
Reasoning:
- JPMS is the lowest-risk option that produces real enforcement
for the most relevant consumer cohorts. ~1 file added (
module-info.java), no source moves, no API renames, no annotation refactor. - Modulepath adoption among Java consumers is increasing every year.
AWS SDK v2 and Spring Boot have already crossed the bar; Java
consumers building anything modular today already write
module-infofiles. By the time this SDK has been in the field for v1.x lifetime, modulepath enforcement will cover the majority of serious consumers. - The classpath asterisk is real but mitigated by IDE warnings and
SemVer tooling that already reads
module-infocorrectly. The consumer who deliberately importsinternal.*from classpath has accepted a one-sided contract — we can change those types freely. - Option B genuinely closes the gap for classpath consumers but at
substantial refactor cost (~25 file moves + deserializer
registration refactor). Worthwhile only if the threat model is
"adversarial classpath consumer". For "respectful classpath consumer
- adversarial modulepath consumer", Option A is sufficient and B is over-engineering.
The strongest counter-argument to Option A is "it is not enforcement, only a strong signal, for classpath consumers." That is correct. The counter-counter-argument is "every Java SDK in our space has the same property; the alternative (Option B) costs more than the gap is worth." If the team disagrees on the threat model, Option B is the escape hatch — but it should be a deliberate choice, not a default.
Option B — Single-package infra. Every infra and resource façade class moves into com.marketdata.sdk and drops the public modifier so it becomes package-private; the compiler then refuses any consumer reference to HttpTransport, RequestSpec, AsyncSemaphore, the wire-format deserializers, or any other internal type. B was chosen over A (JPMS) because the threat model includes classpath consumers — still the majority in 2026 — and JPMS enforcement degrades to a soft IDE warning on classpath, while Option B's enforcement is identical at the language level for both consumer modes.
Move every infra and resource façade class to com.marketdata.sdk, drop public from every type that was internal, refactor @JsonDeserialize annotations off response records into a programmatic SimpleModule registered on HttpTransport's ObjectMapper (response records become data-only, decoupled from wire-format logic), and migrate every test that imports formerly-internal types to the new package — tests for package-private types must live in the matching test package. README's package-layout section needs to reflect the new tree.
- Java Language Specification §7 — Packages
- JEP 261: Module System (JPMS)
- State of the Module System — Mark Reinhold
- Stripe Java SDK — package layout
- AWS SDK for Java v2 — modular layout
revapi— API change tracking with module-info awareness- ADR-001 — Java Only vs. Multi-Language SDK
- ADR-002 — Minimum JDK Version