Skip to content

Latest commit

 

History

History
428 lines (366 loc) · 23.1 KB

File metadata and controls

428 lines (366 loc) · 23.1 KB

ADR-012: Class-Based Clients with Per-Request Config Overrides

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

Context

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.

What was load-bearing

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.

The per-request override dimension

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.

Constraints

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

Decision

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

  1. Receives an OpenCloudClientOptions object containing apiKey and optional configuration (timeout, retry policy, observability hooks, injected HTTP client for testing).
  2. Calls Object.freeze on a shallow copy of those options and stores the frozen object as a private readonly config field.
  3. 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.

Principles

  • Frozen, not just readonly. Object.freeze provides runtime protection against accidental mutation by internal code, on top of the compile-time protection readonly gives. Both are cheap; both are applied.
  • Per-request overrides never mutate the client. this.config is only ever read, never reassigned. A test that fires two calls with different overrides and asserts client.config is unchanged between them must pass by construction, not by discipline.
  • The override type is a true subset of the constructor type. RequestOptions is derived from or mechanically related to OpenCloudClientOptions so the two cannot drift. If a new constructor field is added, the decision about whether it appears in RequestOptions as 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.

Application today

  • @bedrock-rbx/ocale resource clients follow this pattern uniformly. Each of GamePassesClient, DeveloperProductsClient, GameIconsClient, GameThumbnailsClient, and UniversesClient is a class with a frozen config field 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/ocale specifically, not a monorepo-wide pattern.

Consequences

Positive

  • 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 this without 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.create rather than anonymous closure names, a small but persistent debugging quality-of-life win.

Negative

  • Method signatures are uniformly busier. Every public method takes (params, options?) even though most consumers will never pass options. 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 on RequestOptions and covered by tests.
  • RequestOptions must not drift from OpenCloudClientOptions. The two types are closely related — RequestOptions is 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/ocale is 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/ocale itself this ADR is load-bearing.

Neutral

  • Object.freeze is shallow. It protects top-level config fields from reassignment or deletion, but does not deep-freeze nested objects or arrays. For the current OpenCloudClientOptions shape (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 a readonly-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.

Alternatives Considered

Plain functions with config parameter

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.

Factory function returning a closure

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.

Builder pattern

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 with immutable config, without per-request overrides

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.

Implementation Notes

  • RequestOptions derivation. RequestOptions should be derived from OpenCloudClientOptions rather than declared independently, to prevent drift. A Pick<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 the Pick, and the exclusion should be documented in a comment that references this ADR.
  • Shallow-merge documentation. The JSDoc on RequestOptions must explicitly state that overriding an array-valued field replaces the entire array and does not extend it. A test that verifies this behavior for retryableStatuses is 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.

Related Decisions

  • ADR-009: Result types over exceptions — required at the public method boundary of every resource client. client.create(...) returns Promise<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.freeze as the specific mechanism for @bedrock-rbx/ocale.

Amendments

2026-04-20: Per-instance state composed onto an internal ResourceClient

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

References