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.

1

Add the dependency

Add avsb-sdk with Maven or Gradle.

2

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.

3

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.

4

Inject AvsbServer into a controller

Constructor-inject AvsbServer and read typed flags inside your handler methods.

5

Read a flag

Each typed method returns a Flag<T>: the value, plus how the SDK decided it.

6

Track an event

Record a conversion or a metric with avsb.track.

7

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>
XML5 lines
Publishing in progress

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:

application.yaml
avsb:  sdk-key: ${AVSB_SDK_KEY}  # Optional: HTTP timeout for datafile fetches (default 10s)  fetch-timeout: 10s
YAML5 lines

avsb.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:

AvsbWebConfig.java
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")                );            }        });    }}
Java29 lines

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

CheckoutController.java
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()        );    }}
Java31 lines
Where the context comes from

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

Java
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());
Java22 lines

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

Java
EvalContext ctx = AvsbInterceptor.currentContext();avsb.track("purchase", ctx, TrackPayload.revenue(149.99));
Java2 lines

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

Java
EvalContext ctx = EvalContext.user(user.getId(),    Map.of("plan", user.getPlan(), "orgId", user.getOrgId()));boolean showNew = avsb.getBoolFlag("checkout_v2", false, ctx).isEnabled();
Java4 lines

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:

CheckoutControllerTest.java
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);        }    }}
Java56 lines

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:

Console output
[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>").
Plain text1 line

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.
Was this helpful?