Skip to content

feat: user-scoped GrowthBookClient instances via createScopedInstance - #259

Open
vazarkevych wants to merge 2 commits into
mainfrom
feat/user-scoped-instances
Open

vazarkevych wants to merge 2 commits into
mainfrom
feat/user-scoped-instances

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

feat: user-scoped GrowthBookClient instances via createScopedInstance

A lightweight per-user/per-request handle over the shared GrowthBookClient — context-free evaluation + per-user state

TypeScript-SDK parity (createScopedInstance / UserScopedGrowthBook) · additive · shared client reused

build
language level
new tests

Branch: feat/user-scoped-instances → main
Commit: fd8a8ac


Summary

Adds GrowthBookClient.createScopedInstance(UserContext), returning a UserScopedGrowthBook bound to
one user/request. Its evaluation methods (isOn, isOff, evalFeature, getFeatureValue, run, and
the typed FeatureKey variants) take no context argument — every call delegates back to the shared
client, so feature refresh, experiment subscriptions, and remote evaluation are reused, not forked.

The scoped handle also owns per-user state (forced features/variations, attributes, URL, and a
per-user tracking callback). The change is additive and backward compatible.


Why

  • No context threading. Passing a UserContext into every call (isOn(key, ctx),
    getFeatureValue(key, default, ctx), run(experiment, ctx)) means threading the same context through
    every layer of a request; a mismatched context is a silent correctness bug. A scoped instance binds it
    once.
  • Per-user tracking. A scoped instance can set a tracking callback that overrides the client-level
    one for just that user's evaluations (e.g. per-request logging context).
  • Parity with the TS SDK's createScopedInstance / UserScopedGrowthBook.

How it wires up

flowchart LR
    C[GrowthBookClient] -->|createScopedInstance| U[UserScopedGrowthBook]
    U -->|isOn / run / getFeatureValue| C
    U -->|setForced* / setAttributes / setURL / setTrackingCallback| CTX["bound UserContext (rebuilt via toBuilder)"]
    C -->|evaluate with| CTX
    CTX -->|trackingCallback| EV[ExperimentEvaluator.resolveTrackingCallback]
    EV -->|per-user over client-level| T[tracking]
Loading

The scoped instance holds no evaluation logic of its own: it stores a bound UserContext and forwards
to the shared client. Mutators rebuild the context (via UserContext.toBuilder()) rather than mutating
it in place.


What was done

1. UserScopedGrowthBook (multiusermode/UserScopedGrowthBook.java, new)

Per-user handle: context-free evaluation methods that delegate to the client, plus chainable per-user
mutators (setForcedFeatures, setForcedVariations, updateAttributes, setAttributes, setURL,
setTrackingCallback). Documented as single-request/thread use (the client stays the shared, long-lived
instance).

2. GrowthBookClient (multiusermode/GrowthBookClient.java)

createScopedInstance(UserContext) factory.

3. UserContext (multiusermode/configurations/UserContext.java)

Optional trackingCallback (field, builder setter, getter), threaded through the existing copy builders
(witAttributesJson, withAttributes) and a new toBuilder() used by the scoped mutators.

4. ExperimentEvaluator (evaluators/ExperimentEvaluator.java)

resolveTrackingCallback(context) prefers the per-user callback over the client-level one. Applied in
the shared dispatchExperimentViewed path (and the remote-eval early-return guard), so both local
and remote-eval exposures honor a per-user callback.


Files

File Change
multiusermode/UserScopedGrowthBook.java new — per-user handle delegating to the client
multiusermode/GrowthBookClient.java createScopedInstance(UserContext)
multiusermode/configurations/UserContext.java trackingCallback + toBuilder(); threaded through copy builders
evaluators/ExperimentEvaluator.java resolveTrackingCallback (per-user precedence) in the shared dispatch path
multiusermode/UserScopedGrowthBookTest.java new — 13 cases
README.md "User-scoped instances" section

Usage

Java — per-request handle
UserScopedGrowthBook user = gb.createScopedInstance(
    UserContext.builder().attributesJson("{\"id\":\"123\"}").build()
);

boolean newCheckout = user.isOn("new-checkout");
int limit = user.getFeatureValue("upload-limit", 10, Integer.class);
ExperimentResult<String> result = user.run(experiment);

// Per-user state (chainable):
user.setForcedFeatures(Collections.singletonMap("new-checkout", false))
    .setForcedVariations(Collections.singletonMap("pricing-test", 1))
    .updateAttributes(extraAttributes)
    .setURL("https://example.com/checkout");

// Per-user tracking callback (overrides the client-level one for this user):
user.setTrackingCallback((experiment, experimentResult, userContext) -> track(experiment, experimentResult));

Public API surface

Consumer touches Kind Stability
GrowthBookClient.createScopedInstance(UserContext) new method additive
UserScopedGrowthBook (class + methods) new type additive
UserContext.builder().trackingCallback(...) / getTrackingCallback() / toBuilder() new members additive
existing UserContext / ExperimentEvaluator behavior unchanged when no per-user callback is set no break

Tests

UserScopedGrowthBookTest (13 cases) covers: context-free delegation (isOn/isOff/evalFeature/
getFeatureValue); forced features/variations; updateAttributes (merge) vs setAttributes (replace);
setURL targeting; typed FeatureKey accessors; run; and per-user setTrackingCallback taking
precedence over the client-level callback (with fallback when unset). Uses a loopback
com.sun.net.httpserver.HttpServer serving a fixed payload (the repo's established pattern), so it is
deterministic with no real network.

./gradlew build passes on JDK 17; the full :lib suite is green.


Compatibility & scope notes

  • Additive / backward compatible. No signatures removed; per-user tracking defaults to the existing
    client-level callback when unset.
  • Scope. Applies to the multi-user GrowthBookClient path. Single-context GrowthBook/GBContext
    is a different, caller-owned model and is untouched.
  • Ported from the internal feat/user-scoped-instances branch, adapted. Public ExperimentEvaluator
    routes both local and remote exposures through a single dispatchExperimentViewed, so the per-user
    precedence is applied there once rather than at the two separate call sites the internal branch patched.
  • Build: requires JDK 17 (the default JDK 26 crashes the Lombok version used here), matching CI.

Add GrowthBookClient.createScopedInstance(UserContext), returning a lightweight
per-user/per-request UserScopedGrowthBook handle whose evaluation methods (isOn,
isOff, evalFeature, getFeatureValue, run, and the typed FeatureKey variants) take
no context argument. Every call delegates back to the shared client, so feature
refresh, experiment subscriptions, and remote evaluation are reused rather than forked.

The scoped instance also owns per-user state, mutating a bound context:
setForcedFeatures, setForcedVariations, updateAttributes/setAttributes, setURL, and
a per-user setTrackingCallback that overrides the client-level callback for that user,
falling back to it when unset.

To support the per-user callback, UserContext gains an optional trackingCallback
(threaded through its copy builders and a new toBuilder()), and ExperimentEvaluator
prefers it over the client-level one via resolveTrackingCallback — applied in the
shared dispatchExperimentViewed path so both local and remote-eval exposures honor it.
All changes are additive and default to existing behavior.

Mirrors the TS SDK createScopedInstance / UserScopedGrowthBook API.
@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium impact] Adds user-scoped evaluation wrapper with per-user state.

Add the required per-user URL evaluation tests before merging.

Reviews (2) · Last reviewed commit: "fix: prefer per-user URL in scoped evalu..." · Reviewed by Greptile

Comment thread lib/src/test/java/growthbook/sdk/java/multiusermode/UserScopedGrowthBookTest.java Outdated
Scoped URL overrides set through a user-scoped instance were ignored
during local evaluation because evaluators read Options.getUrl()
directly. Add EvaluationContext.resolveUrl(), which prefers
UserContext.getUrl() and falls back to the client-level Options URL
without mutating shared options, and route URL-based targeting and
query-string overrides in FeatureEvaluator and ExperimentEvaluator
through it.

Replace the loopback com.sun HttpServer fixture in
UserScopedGrowthBookTest with WireMock per the repository testing
convention.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment on lines +46 to +51
public String resolveUrl() {
String userUrl = this.user != null ? this.user.getUrl() : null;
if (userUrl != null) {
return userUrl;
}
return this.options != null ? this.options.getUrl() : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Scoped URL overrides lack tests

resolveUrl() changes which URL feature and experiment overrides use, but no test checks evaluation with a per-user URL. The existing setURL test only checks the saved value; other override tests use Options.setUrl().

The repository guide requires tests for every behavior change, including valid, invalid, and absent inputs. Before merging, add tests for per-user precedence, client fallback, and empty or malformed URLs in both override paths.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/EvaluationContext.java
Line: 46-51

Comment:
**Scoped URL overrides lack tests**

`resolveUrl()` changes which URL feature and experiment overrides use, but no test checks evaluation with a per-user URL. The existing `setURL` test only checks the saved value; other override tests use `Options.setUrl()`.

The repository guide requires tests for every behavior change, including valid, invalid, and absent inputs. Before merging, add tests for per-user precedence, client fallback, and empty or malformed URLs in both override paths.

**Context Used:** AGENTS.md ([source](https://github.com/growthbook/growthbook-sdk-java/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant