Date: 2026-04-12 Status: Accepted
Refines: ADR-011 (library/SDK packages use a simplified architecture)
Decision Makers: Maintainer Tags: architecture, open-cloud, api-design, sdk
ADR-011 established that @bedrock-rbx/ocale may use a simplified architecture
in place of FCIS + Ports, and listed "immutable configuration" as a principle
opt-out packages must respect. That principle is intentionally shape-agnostic:
it says a package's configuration must not change underneath a consumer once the
package has handed them a usable handle, but it says nothing about how that
handle should be shaped. Classes, closure factories, builders, and bare
functions could each satisfy the principle.
This ADR picks a specific mechanism for @bedrock-rbx/ocale: each resource is a
class with config frozen at construction via Object.freeze, and every
public method takes an optional second argument — a subset of the
construction-time options — whose fields override the constructor config for
that single request. The second-parameter override pattern is borrowed from SDKs
generated by Stainless (OpenAI's Node SDK, Anthropic's SDK, Cloudflare's, and
others) and is common enough in modern TypeScript SDKs that most consumers will
have encountered it before.
This ADR catches the decision log up to what already exists on disk. The package
was restructured to this pattern in commit fa173bc alongside the rest of the
work ADR-011 documents; the class + override shape has been in the source tree
without a dedicated ADR. The original choice was made based on existing industry
patterns rather than a documented forcing function — the maintainer considered a
function-oriented approach and found it "didn't properly fit," but the detailed
rationale was not written down at the time. The sections below reconstruct that
rationale honestly: what was load-bearing, what was incidental, and what was
discovered only after the fact.
The primary friction with function-oriented alternatives was auth threading.
In a plain-function style, apiKey (and secondary config like timeout,
maxRetries, baseUrl) would need to be passed explicitly to every call site.
Users would either repeat the same options object at every call — noisy and
error-prone — or construct their own shared config variable and thread it
manually, recreating the concept of "a configured client" ad-hoc outside the
SDK. Closure-factory approaches solved the threading problem but did so by
reconstructing object-orientation with extra steps: a function that captures
config and returns an object whose methods close over it is structurally a
class, just without the class's type name, error-message affordances, or
instanceof support.
A secondary but genuine concern is per-instance internal state. The
rate-limiting design from the package's plan maintains a
Map<string, RateLimitQueue> keyed by API key; observability hooks
(onRequest, onRetry, onRateLimit) are registered once per instance; retry
configuration is resolved once per call using instance-level defaults. Classes
give this state a natural home. Closures can carry it too, but at the cost of
the ceremony described above.
Industry convention reinforces the class choice but is not itself
load-bearing. Every modern TypeScript SDK that @bedrock-rbx/ocale consumers
are likely to have touched — OpenAI, Anthropic, AWS SDK v3, Stripe, Octokit —
uses a class-based shape with constructor config. Matching that shape lowers the
learning curve, but it is not a reason that could survive a strong technical
argument against it.
Class-based clients do not require per-request overrides. A simpler API would
force users to instantiate multiple clients when they need multiple API keys
("one mainClient, one assetClient") and route calls to the appropriate
client at each site. The decision to layer Stainless-style overrides on top was
motivated by anticipated multi-key workflows: distributing work across multiple
API keys to pool rate-limit quota, and using separate keys for asset uploads to
isolate moderation risk. These use cases are partially but not definitively
concrete — real near-term candidates exist, but the SDK is not blocked on a
specific user demand for v0.1.
Shallow-merge override semantics ({ ...this.config, ...options }) were chosen
for simplicity over deep merge or structured merge. Overriding an array-valued
field like retryableStatuses replaces the whole array rather than extending
it. This is consistent with how Stainless-generated SDKs behave and is the least
surprising behavior for a Partial-shaped options type, but it requires
explicit documentation.
- ADR-011's "immutable configuration" principle must hold: once a client instance has been handed to a consumer, its default config must not change for the instance's lifetime.
- ADR-009 (Result types over exceptions) must hold at public method boundaries regardless of client shape.
- The HTTP seam must remain injectable for test fakes, per ADR-011's testability principle.
- The chosen shape must not foreclose the pure-builder / pure-parser structure that ADR-011 requires.
@bedrock-rbx/ocale resource clients are classes. Each public resource (one
per Open Cloud service category — GamePassesClient, DeveloperProductsClient,
GameIconsClient, GameThumbnailsClient, UniversesClient) is a class
constructed with an OpenCloudClientOptions object. Construction:
- Receives an
OpenCloudClientOptionsobject containingapiKeyand optional configuration (timeout, retry policy, observability hooks, injected HTTP client for testing). - Calls
Object.freezeon a shallow copy of those options and stores the frozen object as aprivate readonly configfield. - Initializes per-instance internal state (the rate-limit queue map, etc.).
After construction, the client's config is not replaced, reassigned, or mutated
for the lifetime of the instance. There is no setApiKey, no reconfigure, no
mutable setter.
Every public method takes an optional second argument of type
RequestOptions, a subset of OpenCloudClientOptions covering the fields that
are meaningful to override per request (apiKey, baseUrl, timeout,
maxRetries, retryableStatuses, retryDelay). Fields not meaningful at
request time — notably httpClient, which is a construction-time test seam, and
the observability hooks, which are client-level concerns — are absent from
RequestOptions.
The override mechanism is implemented as a shallow spread merge at the top of
each method, { ...this.config, ...options }, with per-request options
winning on conflict. The merged object is used for the duration of that single
request and is not written back to this.config. Array-valued fields in the
merge are replaced, not extended.
- Frozen, not just
readonly.Object.freezeprovides runtime protection against accidental mutation by internal code, on top of the compile-time protectionreadonlygives. Both are cheap; both are applied. - Per-request overrides never mutate the client.
this.configis only ever read, never reassigned. A test that fires two calls with different overrides and assertsclient.configis unchanged between them must pass by construction, not by discipline. - The override type is a true subset of the constructor type.
RequestOptionsis derived from or mechanically related toOpenCloudClientOptionsso the two cannot drift. If a new constructor field is added, the decision about whether it appears inRequestOptionsas well must be explicit. - Internal state is keyed where it matters, not where it is allocated.
Rate-limit queues live in a
Map<string, RateLimitQueue>on the client instance, keyed by the effective API key for a request — which may come from the constructor default or from a per-request override. The same key always routes through the same queue, regardless of which call site supplied it.
@bedrock-rbx/ocaleresource clients follow this pattern uniformly. Each ofGamePassesClient,DeveloperProductsClient,GameIconsClient,GameThumbnailsClient, andUniversesClientis a class with a frozenconfigfield and(params, options?)method signatures.- Future opt-out packages (packages that satisfy ADR-011's five-criteria
rubric) are not bound to this decision. They must satisfy ADR-011's
shape-agnostic "immutable configuration" principle, but they may choose
classes, closure factories, or any other mechanism that honors it. This ADR is
a decision for
@bedrock-rbx/ocalespecifically, not a monorepo-wide pattern.
- Config lives in one place. The
apiKey(and every other option) is set once, at construction, and threads through all subsequent calls implicitly. Call sites are uncluttered; the user cannot get the key wrong on an individual call without explicitly writing an override. - Per-instance state has a natural home. Rate-limit queues, observability
hooks, and retry bookkeeping all live on
thiswithout ceremony. No closure gymnastics, no module-level singletons, no dependency-injection container. - Multi-key workflows are correct by construction. Because rate-limit queues are keyed by API key inside a single client instance, distributing work across multiple keys through per-request overrides routes every request for a given key through the same queue. The SDK's internal rate limiter stays in sync with the server's actual quota per key, regardless of where the request originated in the user's code. By contrast, a two-clients-per-key approach has a subtle correctness hazard: two client instances that share a key maintain independent queues that do not coordinate, silently double-spending the key's quota and producing 429s the SDK should have prevented. This property was discovered after the original class-based choice; it reinforces rather than justifies the decision, but it is a meaningful argument against retroactively simplifying the design to drop overrides.
- Familiar shape for SDK consumers. Matching the Stainless convention lowers the learning curve for anyone who has used OpenAI, Anthropic, AWS v3, Cloudflare, or other modern TypeScript SDKs. Resource-per-class with dotted access is the dominant idiom in the ecosystem the user will be reaching from.
- Error messages name the site. Stack frames read
GamePassesClient.createrather than anonymous closure names, a small but persistent debugging quality-of-life win.
- Method signatures are uniformly busier. Every public method takes
(params, options?)even though most consumers will never passoptions. The second argument appears in IntelliSense and generated docs for every method and adds a small amount of visual noise at the type level. - Shallow-merge semantics require documentation. Users who assume deep merge
will be surprised that overriding
retryableStatuses: [429]replaces the default array rather than appending to it. This must be explicitly documented onRequestOptionsand covered by tests. RequestOptionsmust not drift fromOpenCloudClientOptions. The two types are closely related —RequestOptionsis a subset of the construction-time options — and adding a field to one without the other is a silent hazard. The package must derive one from the other mechanically (e.g.,Pick<OpenCloudClientOptions, ...>) or enforce synchronization via a type-level test.- The decision is package-specific.
@bedrock-rbx/ocaleis now committed to a class-oriented API shape. A future refactor toward, say, a functional resource-module pattern would be a breaking change for consumers. ADR-011's shape-agnostic principle leaves room for other opt-out packages to make different choices, but for@bedrock-rbx/ocaleitself this ADR is load-bearing.
Object.freezeis shallow. It protects top-level config fields from reassignment or deletion, but does not deep-freeze nested objects or arrays. For the currentOpenCloudClientOptionsshape (primitives, function-valued hooks, a small array of retryable statuses) this is sufficient, because the nested values are either immutable by convention or are not stored in ways that would be mutated by the SDK's own code. If the options object grows nested mutable state, this ADR should be revisited in favor of a deeper freeze or areadonly-enforcing factory.- Rate-limit queues per key are lazy. Queues are allocated on first use for each unique key the client sees, not pre-allocated at construction. The first override against a new key incurs the small cost of allocating a queue for it.
- Observability hooks fire from the client, not per-request. Hooks registered at construction are called for every request the client makes, regardless of whether per-request overrides were used. Users who want different hooks for different keys must still instantiate multiple clients — per-request overrides do not extend to callback registration. This is deliberate: hooks are a client-level concern, not a per-request concern.
Each operation is a free function that takes config as an explicit parameter:
await createGamePass(
{ name: "VIP Pass", priceInRobux: 100, universeId: "123" },
{ apiKey, maxRetries, retryableStatuses, timeout },
);Rejected. Auth and config are repeated at every call site, or the user builds their own ad-hoc "configured client" variable to thread manually — either way, the SDK is pushing the work of maintaining stable configuration onto its consumers. There is no single object to hang per-instance state (rate-limit queues, observability hooks) on, so rate limiting and retry bookkeeping would have to move to module-level globals or be passed alongside the config at every call. This approach fails the primary motivation — eliminating auth-threading friction — and regresses the rate-limiter correctness property described in the Consequences section.
A factory function captures config and returns an object whose methods close over it:
export function createGamePassesClient(
options: OpenCloudClientOptions,
): GamePassesClient {
const config = Object.freeze({ ...options });
const queues = new Map<string, RateLimitQueue>();
return {
create: async (parameters, overrides?) => {
/* ... */
},
get: async (id, overrides?) => {
/* ... */
},
};
}Rejected. Functionally equivalent to a class — config is captured, internal
state lives in the closure, methods are co-located — but structurally it is a
class with no class keyword, and the differences are all losses rather than
gains. There is no nominal type name for the returned object: stack traces show
anonymous frames; instanceof cannot be used; consumer-facing type signatures
default to inferred structural types or require an exported interface that
mirrors the returned shape anyway. The idiom diverges from every comparable SDK
in the ecosystem for no payoff. The pragmatic reading of "functional core,
imperative shell" does not require avoiding class in the imperative shell; it
requires keeping the core pure, which this ADR does via pure request builders
and pure response parsers regardless of whether the shell is class-shaped.
A separate GamePassesClientBuilder accumulates configuration via fluent
.withX(...) calls and produces a client via .build():
const builder = new GamePassesClientBuilder();
const client = builder.withApiKey("main-key").withTimeout(10_000).build();Rejected. Builders earn their keep when configuration has many optional
fields with cross-field validation, ordering requirements, or a meaningful
terminal build step. OpenCloudClientOptions is a flat object of
mostly-optional fields with no ordering constraints and no validation beyond
"apiKey must exist." Passing an options literal to the constructor gives
consumers IntelliSense-driven configuration with the same ergonomics and half
the API surface. The builder pattern also does not address per-request overrides
— it only changes how the initial client is constructed — so it is orthogonal
to the second dimension of this decision.
Classes as chosen, but no optional second parameter. Users who need multiple API keys instantiate multiple clients:
const mainClient = new GamePassesClient({ apiKey: "main-key" });
const assetClient = new GamePassesClient({ apiKey: "asset-key" });Rejected. This approach fails the multi-key rate-limit correctness property.
Two client instances that happen to share an API key (deliberately for different
workloads, or accidentally via a factory or DI container) maintain independent
Map<string, RateLimitQueue> fields that do not coordinate, so the SDK silently
allows both clients to exhaust the same server-side quota and the built-in rate
limiter produces a false sense of safety. A single client with per-request
overrides routes every request for a given key through the same internal queue
by construction. Additionally, the ergonomics of the parallelization use case —
distributing work across N keys to pool quota — degrade under this approach:
users must maintain an array of N clients and round-robin manually, when the
natural mental model is "one client, many keys." The correctness argument is the
load-bearing half of the rejection; the ergonomic argument is secondary.
RequestOptionsderivation.RequestOptionsshould be derived fromOpenCloudClientOptionsrather than declared independently, to prevent drift. APick<OpenCloudClientOptions, "apiKey" | "baseUrl" | "timeout" | "maxRetries" | "retryableStatuses" | "retryDelay">(or equivalent) is the mechanical relationship this ADR requires. Fields that are construction-only —httpClient,onRequest,onRetry,onRateLimit— must be excluded from thePick, and the exclusion should be documented in a comment that references this ADR.- Shallow-merge documentation. The JSDoc on
RequestOptionsmust explicitly state that overriding an array-valued field replaces the entire array and does not extend it. A test that verifies this behavior forretryableStatusesis the mechanical expression of the documentation. - Frozen-config test. Each resource client must have a test that constructs an instance, makes a call with per-request overrides, and asserts that the client's stored config is unchanged (shape-equal to the original construction argument). This is the mechanical expression of the "per-request overrides never mutate the client" principle.
- Rate-limit queue keying. The internal
Map<string, RateLimitQueue>must key on the effective API key for a request (post-merge), not on the constructor default. A test that fires two requests against the same client with two different overridden keys and verifies two separate queues exist is the mechanical expression of the "correct by construction" consequence. - Future opt-out packages. Future library packages opting into ADR-011's simplified architecture are not required to follow this ADR's class-oriented shape. They must honor ADR-011's shape-agnostic "immutable configuration" principle, but the choice between classes, closures, and other mechanisms is theirs to make, with their own ADR if the decision is non-trivial.
- ADR-009: Result types over exceptions — required at the public method
boundary of every resource client.
client.create(...)returnsPromise<Result<T, OpenCloudError>>, not a value that may throw. - ADR-010: SDK-managed rate limiting and retry — the rate-limit queues whose correctness is discussed in this ADR's Consequences section. ADR-010 establishes that the SDK (not the CLI) owns retry and queuing; this ADR picks the client shape that holds that state.
- ADR-011: Simplified architecture for library packages — the parent
decision this ADR refines. ADR-011 establishes the shape-agnostic "immutable
configuration" principle; this ADR picks classes with
Object.freezeas the specific mechanism for@bedrock-rbx/ocale.
Decision §Application today originally described every resource client as a
class that owns its config field, its Map<string, RateLimitQueue>, and its
retry orchestration directly. The shape worked for one resource but scaled
multiplicatively: each new resource copied roughly 130 lines of plumbing out of
GamePassesClient, and the Implementation Notes required every resource to
re-assert the frozen-config and queue-per-effective-key invariants in its own
spec.
Landed: the plumbing moved onto a single internal ResourceClient
(packages/open-cloud/src/internal/resource-client.ts). Resource classes
compose one instance and declare per-method ResourceMethodSpec constants that
bind a builder, a parser, a method kind, method defaults, and an operation
limit. Public methods are one-line delegations to
ResourceClient.execute({ spec, parameters, options }). GamePassesClient is
the first and currently only consumer; future resource clients on the roadmap
(Places, Developer Products, Thumbnails, Badges, Assets) adopt the same
composition.
The Decision and Principles are unchanged. The class-based shape, frozen config,
per-request overrides, shallow-merge semantics, and effective- apiKey queue
keying are all preserved. What moved is where the state lives: on the composed
ResourceClient rather than on each resource class. The frozen-config and
queue-keying invariant tests named in Implementation Notes now live once in
src/internal/resource-client.spec.ts rather than once per resource.
Resource-level specs cover resource-specific behaviour (URL shape, request body,
parser integration) and no longer re-assert the cross-cutting invariants.
ResourceClient is not exported from any package subpath. Consumers continue to
import resource clients from their subpaths (@bedrock-rbx/ocale/game-passes,
and so on).
- OpenAI Node.js SDK — canonical Stainless-generated SDK using the class + per-request override pattern
- Anthropic TypeScript SDK — Stainless-generated; same shape
- AWS SDK v3 client pattern — independently implemented class-based SDK with per-request middleware for overrides
- Stainless: SDK API best practices — documented rationale for the per-request override pattern in generated SDKs
- Open Cloud Package Design Plan — the package design document describing the class-based implementation this ADR catches up with