Spring Boot
This guide targets Spring Boot 3.x with Java 17 or later. The avsb-sdk library is the same Java SDK used everywhere else. It ships with Spring Boot auto-configuration built in: add the dependency, set one property, and it registers AvsbServer as a singleton bean for you. Add your own HandlerInterceptor, by extending AvsbInterceptor, to build the per-request identity every flag read needs. By the end you will read flags inside a controller with no manual setup code.
Add the dependency
Add avsb-sdk with Maven or Gradle.
Obtain your SDK key
Open your A vs B project, go to Environments in the project sidebar, and copy the SDK key. Set it as avsb.sdk-key in application.yaml.
Configure context scoping (optional)
Extend AvsbInterceptor and register it as a Spring MVC interceptor. It builds an EvalContext from each request, before your controller runs.
Inject AvsbServer into a controller
Constructor-inject AvsbServer and read typed flags inside your handler methods.
Read a flag
Each typed method returns a Flag<T>: the value, plus how the SDK decided it.
Track an event
Record a conversion or a metric with avsb.track.
Identify a user
Build a different EvalContext when you need to evaluate flags for someone other than the request's own visitor, for example after a service-to-service token exchange.
<dependency> <groupId>com.avsbhq</groupId> <artifactId>avsb-sdk</artifactId> <version>1.0.1</version></dependency>implementation("com.avsbhq:avsb-sdk:1.0.1")Version 1.0.1 of com.avsbhq:avsb-sdk is on its way to Maven Central with this release. Until it arrives there, Maven and Gradle report that they cannot resolve it.
Spring Boot finds the auto-configuration on its own the moment avsb-sdk is on the classpath. There is nothing to @Import by hand.
Set your SDK key in application.yaml:
avsb: sdk-key: ${AVSB_SDK_KEY} # Optional: HTTP timeout for datafile fetches (default 10s) fetch-timeout: 10savsb.sdk-key is the only setting the auto-configuration needs. Once it is set, AvsbServer starts fetching the datafile: a small JSON file that lists every flag, its variations, and the rules that decide who gets which one.
Configure context scoping
Extend AvsbInterceptor and override buildContext to read the visitor's identity off the request, then register the interceptor with a WebMvcConfigurer:
package com.example.config;import com.avsbhq.avsb.EvalContext;import com.avsbhq.avsb.middleware.spring.AvsbInterceptor;import jakarta.servlet.http.HttpServletRequest;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import java.util.Map;@Configurationpublic class AvsbWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AvsbInterceptor() { @Override protected EvalContext buildContext(HttpServletRequest request) { String userId = request.getHeader("X-User-Id"); String plan = request.getHeader("X-User-Plan"); return EvalContext.user( userId != null ? userId : "anon", Map.of("plan", plan != null ? plan : "free") ); } }); }}AvsbInterceptor stores the context it builds in a thread-local for the length of the request, then clears it once the response is sent. Any controller can read it back with AvsbInterceptor.currentContext(). Skip this step and every flag read falls back to one shared, empty identity: see "The one mistake people make with this framework" near the end of this page.
Inject AvsbServer into a controller
package com.example.checkout;import com.avsbhq.avsb.AvsbServer;import com.avsbhq.avsb.Flag;import com.avsbhq.avsb.middleware.spring.AvsbInterceptor;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import java.util.Map;@RestControllerpublic class CheckoutController { private final AvsbServer avsb; public CheckoutController(AvsbServer avsb) { this.avsb = avsb; } @GetMapping("/checkout") public Map<String, Object> show() { var ctx = AvsbInterceptor.currentContext(); Flag<Boolean> checkout = avsb.getBoolFlag("checkout_v2", false, ctx); Flag<String> theme = avsb.getStringFlag("ui_theme", "default", ctx); return Map.of( "showNewCheckout", checkout.value(), "theme", theme.value() ); }}AvsbServer is an ordinary singleton bean: the same instance answers every request, and on its own it has no idea who is asking. Every read takes the identity as a parameter. AvsbInterceptor.currentContext() hands back the EvalContext your interceptor built for this request.
Reading flags of different types
EvalContext ctx = AvsbInterceptor.currentContext();// BooleanFlag<Boolean> flag = avsb.getBoolFlag("checkout_v2", false, ctx);if (flag.isEnabled()) { /* new experience */ }// StringFlag<String> theme = avsb.getStringFlag("ui_theme", "default", ctx);// Number: every number flag resolves to a DoubleFlag<Double> timeout = avsb.getNumberFlag("api_timeout_secs", 30.0, ctx);// JSON, mapped into your own typerecord PricingConfig(String plan, double amount) {}Flag<PricingConfig> config = avsb.getJsonFlag( "pricing_config", new PricingConfig("standard", 9.99), FlagValueType.json(PricingConfig.class), ctx);// Metadata: Flag<T> is a record, so its accessors have no "get" prefixSystem.out.printf("Source: %s, Variation: %s%n", flag.source(), flag.variationKey());A variation is one version being tested: control, or one of the challengers. isEnabled() is true only when a real rule, holdout, or override decided the value, not merely because the value itself happens to be truthy.
Tracking an event
EvalContext ctx = AvsbInterceptor.currentContext();avsb.track("purchase", ctx, TrackPayload.revenue(149.99));There is no .property(...) call to attach extra fields. The metric event has no properties bag: anything sent that way would look delivered and then be dropped before it reached storage. Put a number in TrackPayload.value(...) or .revenue(...), and put anything you want to segment by on the EvalContext's attributes instead.
Identify a user
EvalContext ctx = EvalContext.user(user.getId(), Map.of("plan", user.getPlan(), "orgId", user.getOrgId()));boolean showNew = avsb.getBoolFlag("checkout_v2", false, ctx).isEnabled();There is no separate scoped client to create. Build the EvalContext you need and pass it straight into the read.
Graceful shutdown
AvsbServer implements AutoCloseable. Its close() method stops the background refresh loop and flushes any events still queued, then returns. The auto-configuration's bean method sets no explicit destroy method, so Spring's default destroy-method inference finds close() and calls it when the application context shuts down. No extra configuration is required.
Testing
There is no mock auto-configuration and no fake client. A test builds a real AvsbServer from a small datafile you write inline, using TestUtils. Polling, event sending, and logging are all off, so the test never touches the network:
package com.example.checkout;import com.avsbhq.avsb.AvsbServer;import com.avsbhq.avsb.TestUtils;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;import org.springframework.boot.test.context.TestConfiguration;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Import;import org.springframework.test.web.servlet.MockMvc;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;@WebMvcTest(CheckoutController.class)@Import(CheckoutControllerTest.TestAvsbConfig.class)class CheckoutControllerTest { @Autowired MockMvc mockMvc; // A minimal, real datafile document. This flag has no targeting rules, // so it always serves defaultVariationId. private static final String DATAFILE = """ { "version": 2, "flags": [ { "id": "flag_1", "key": "checkout_v2", "type": "boolean", "enabled": true, "defaultVariationId": "var_on", "variations": [ { "id": "var_off", "key": "off", "value": false }, { "id": "var_on", "key": "on", "value": true } ] } ] } """; @Test void showsNewCheckout_whenFlagDefaultsOn() throws Exception { mockMvc.perform(get("/checkout")) .andExpect(jsonPath("$.showNewCheckout").value(true)); } @TestConfiguration static class TestAvsbConfig { @Bean AvsbServer avsbServer() { return TestUtils.serverFromJson("sdk_test_000000000000", DATAFILE); } }}To test the "off" branch too, change defaultVariationId to var_off in a second datafile string. To force one variation without touching the JSON, build the server with AvsbServer.builder(...).bootstrapDatafile(...).runtimeOverrides(Map.of("checkout_v2", "off")) instead of TestUtils.serverFromJson.
The one mistake people make with this framework
The most common mistake is skipping the interceptor step. Without it, AvsbInterceptor.currentContext() always returns null, and every flag read falls back to one shared, empty identity. The SDK warns once, to the console the auto-configuration wires up by default:
[AVSB WARN] AvsB evaluated a flag with an empty bucketing key, so every request will land in the same bucket and experiment results will be wrong. Pass a real identity: EvalContext.user("<your user id>").Nothing crashes, and nothing looks broken. Every visitor lands in the same bucket, and the experiment's results are worthless: one bucket standing in for your whole audience.
What's next
- ASP.NET Core integration: the same auto-configuration pattern for .NET.
- Sticky bucketing: keep a user in the variation they were first assigned, with the SDK's Redis-backed store or one you write yourself.