Back to Journal
SaaS Engineering

How to Build Feature Flag Architecture Using Spring Boot

Step-by-step tutorial for building Feature Flag Architecture with Spring Boot, from project setup through deployment.

Muneer Puthiya Purayil 25 min read

This tutorial builds a production-grade feature flag system with Spring Boot, covering flag evaluation, JPA persistence, REST management API, percentage rollouts with sticky bucketing, AOP-based feature gating, and actuator integration for monitoring.

Project Setup

bash
1curl https://start.spring.io/starter.zip \
2 -d dependencies=web,data-jpa,postgresql,validation,actuator,aop \
3 -d type=maven-project \
4 -d language=java \
5 -d javaVersion=21 \
6 -d name=feature-flag-service \
7 -o feature-flag-service.zip
8unzip feature-flag-service.zip && cd feature-flag-service
9 
properties
1# application.properties
2spring.datasource.url=jdbc:postgresql://localhost:5432/flags
3spring.datasource.username=postgres
4spring.datasource.password=postgres
5spring.jpa.hibernate.ddl-auto=validate
6feature-flags.sync-interval=10s
7 

Domain Model

java
1@Entity
2@Table(name = "feature_flags")
3public class FeatureFlag {
4 @Id
5 @GeneratedValue(strategy = GenerationType.UUID)
6 private String id;
7 
8 @Column(unique = true, nullable = false)
9 private String key;
10 
11 @Column(nullable = false)
12 private String name;
13 
14 private String description;
15 private boolean enabled;
16 private double percentage = 100.0;
17 
18 @Column(columnDefinition = "jsonb")
19 @JdbcTypeCode(SqlTypes.JSON)
20 private List<TargetingRule> rules = new ArrayList<>();
21 
22 @Enumerated(EnumType.STRING)
23 private FlagType type = FlagType.RELEASE;
24 
25 private String owner;
26 private String defaultVariant = "";
27 private LocalDateTime expiresAt;
28 private LocalDateTime createdAt;
29 private LocalDateTime updatedAt;
30 
31 @PrePersist
32 void onCreate() { createdAt = updatedAt = LocalDateTime.now(); }
33 
34 @PreUpdate
35 void onUpdate() { updatedAt = LocalDateTime.now(); }
36}
37 
38public record TargetingRule(
39 List<Condition> conditions,
40 String variant,
41 int priority
42) {}
43 
44public record Condition(
45 String attribute,
46 String operator,
47 List<String> values
48) {}
49 
50public enum FlagType { RELEASE, EXPERIMENT, OPS, PERMISSION }
51 

Flag Evaluator Service

java
1@Service
2public class FlagEvaluator {
3 private volatile Map<String, FeatureFlag> flags = Map.of();
4 
5 public void updateFlags(List<FeatureFlag> flagList) {
6 this.flags = flagList.stream()
7 .collect(Collectors.toUnmodifiableMap(FeatureFlag::getKey, Function.identity()));
8 }
9 
10 public EvaluationResult evaluate(String flagKey, EvaluationContext context) {
11 var flag = flags.get(flagKey);
12 if (flag == null) return EvaluationResult.notFound(flagKey);
13 if (!flag.isEnabled()) return EvaluationResult.disabled(flagKey);
14 
15 for (var rule : flag.getRules().stream()
16 .sorted(Comparator.comparingInt(TargetingRule::priority).reversed())
17 .toList()) {
18 if (matchesAll(rule.conditions(), context)) {
19 return EvaluationResult.enabled(flagKey, rule.variant(), "rule_match");
20 }
21 }
22 
23 if (flag.getPercentage() > 0 && flag.getPercentage() < 100) {
24 double bucket = computeBucket(flagKey, context.userId());
25 if (bucket < flag.getPercentage()) {
26 return EvaluationResult.enabled(flagKey, flag.getDefaultVariant(), "percentage");
27 }
28 return EvaluationResult.disabled(flagKey, "percentage_excluded");
29 }
30 
31 return EvaluationResult.enabled(flagKey, flag.getDefaultVariant(), "default");
32 }
33 
34 public boolean isEnabled(String flagKey, EvaluationContext context) {
35 return evaluate(flagKey, context).enabled();
36 }
37 
38 private double computeBucket(String flagKey, String userId) {
39 try {
40 var digest = MessageDigest.getInstance("SHA-256");
41 byte[] hash = digest.digest((flagKey + ":" + userId).getBytes(StandardCharsets.UTF_8));
42 long value = Integer.toUnsignedLong(
43 (hash[0] & 0xFF) << 24 | (hash[1] & 0xFF) << 16 |
44 (hash[2] & 0xFF) << 8 | (hash[3] & 0xFF));
45 return (value / (double) 0xFFFFFFFFL) * 100;
46 } catch (NoSuchAlgorithmException e) {
47 throw new RuntimeException(e);
48 }
49 }
50 
51 private boolean matchesAll(List<Condition> conditions, EvaluationContext ctx) {
52 return conditions.stream().allMatch(c -> matchCondition(c, ctx));
53 }
54 
55 private boolean matchCondition(Condition cond, EvaluationContext ctx) {
56 String value = getAttribute(cond.attribute(), ctx);
57 return switch (cond.operator()) {
58 case "eq" -> !cond.values().isEmpty() && cond.values().get(0).equals(value);
59 case "neq" -> !cond.values().isEmpty() && !cond.values().get(0).equals(value);
60 case "in" -> cond.values().contains(value);
61 case "contains" -> !cond.values().isEmpty() && value.contains(cond.values().get(0));
62 case "starts_with" -> !cond.values().isEmpty() && value.startsWith(cond.values().get(0));
63 default -> false;
64 };
65 }
66 
67 private String getAttribute(String attr, EvaluationContext ctx) {
68 return switch (attr) {
69 case "user_id" -> ctx.userId();
70 case "email" -> ctx.email();
71 case "plan" -> ctx.plan();
72 case "country" -> ctx.country();
73 default -> ctx.properties().getOrDefault(attr, "");
74 };
75 }
76}
77 
78public record EvaluationContext(
79 String userId,
80 String email,
81 String plan,
82 String country,
83 Map<String, String> properties
84) {
85 public EvaluationContext(String userId) {
86 this(userId, "", "", "", Map.of());
87 }
88}
89 
90public record EvaluationResult(String flagKey, boolean enabled, String variant, String reason) {
91 public static EvaluationResult enabled(String key, String variant, String reason) {
92 return new EvaluationResult(key, true, variant, reason);
93 }
94 public static EvaluationResult disabled(String key) {
95 return new EvaluationResult(key, false, "", "disabled");
96 }
97 public static EvaluationResult disabled(String key, String reason) {
98 return new EvaluationResult(key, false, "", reason);
99 }
100 public static EvaluationResult notFound(String key) {
101 return new EvaluationResult(key, false, "", "not_found");
102 }
103}
104 

Sync Scheduler

java
1@Component
2@RequiredArgsConstructor
3public class FlagSyncScheduler {
4 private final FlagRepository flagRepository;
5 private final FlagEvaluator evaluator;
6 
7 @PostConstruct
8 public void initialSync() {
9 sync();
10 }
11 
12 @Scheduled(fixedDelayString = "${feature-flags.sync-interval}")
13 public void sync() {
14 var flags = flagRepository.findAll();
15 evaluator.updateFlags(flags);
16 }
17}
18 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

AOP Feature Gate

java
1@Target(ElementType.METHOD)
2@Retention(RetentionPolicy.RUNTIME)
3public @interface FeatureGate {
4 String value();
5}
6 
7@Aspect
8@Component
9@RequiredArgsConstructor
10public class FeatureGateAspect {
11 private final FlagEvaluator evaluator;
12 
13 @Around("@annotation(gate)")
14 public Object checkGate(ProceedingJoinPoint jp, FeatureGate gate) throws Throwable {
15 var request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
16 .getRequest();
17 
18 var context = new EvaluationContext(
19 request.getHeader("X-User-ID"),
20 request.getHeader("X-Email"),
21 request.getHeader("X-Plan"),
22 request.getHeader("CF-IPCountry"),
23 Map.of()
24 );
25 
26 if (!evaluator.isEnabled(gate.value(), context)) {
27 throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Feature not available");
28 }
29 
30 return jp.proceed();
31 }
32}
33 

REST API Controller

java
1@RestController
2@RequestMapping("/api/flags")
3@RequiredArgsConstructor
4public class FlagController {
5 private final FlagRepository flagRepo;
6 private final FlagEvaluator evaluator;
7 private final FlagSyncScheduler syncScheduler;
8 
9 @GetMapping
10 public List<FeatureFlag> listFlags() {
11 return flagRepo.findAll();
12 }
13 
14 @PostMapping
15 @ResponseStatus(HttpStatus.CREATED)
16 public FeatureFlag createFlag(@Valid @RequestBody CreateFlagRequest req) {
17 var flag = new FeatureFlag();
18 flag.setKey(req.key());
19 flag.setName(req.name());
20 flag.setDescription(req.description());
21 flag.setEnabled(req.enabled());
22 flag.setPercentage(req.percentage());
23 flag.setType(req.type());
24 flag.setOwner(req.owner());
25 var saved = flagRepo.save(flag);
26 syncScheduler.sync();
27 return saved;
28 }
29 
30 @PutMapping("/{key}")
31 public FeatureFlag updateFlag(@PathVariable String key, @RequestBody UpdateFlagRequest req) {
32 var flag = flagRepo.findByKey(key).orElseThrow(() ->
33 new ResponseStatusException(HttpStatus.NOT_FOUND));
34 if (req.enabled() != null) flag.setEnabled(req.enabled());
35 if (req.percentage() != null) flag.setPercentage(req.percentage());
36 if (req.rules() != null) flag.setRules(req.rules());
37 var saved = flagRepo.save(flag);
38 syncScheduler.sync();
39 return saved;
40 }
41 
42 @PostMapping("/{key}/toggle")
43 public FeatureFlag toggleFlag(@PathVariable String key) {
44 var flag = flagRepo.findByKey(key).orElseThrow(() ->
45 new ResponseStatusException(HttpStatus.NOT_FOUND));
46 flag.setEnabled(!flag.isEnabled());
47 var saved = flagRepo.save(flag);
48 syncScheduler.sync();
49 return saved;
50 }
51 
52 @PostMapping("/evaluate")
53 public EvaluationResult evaluate(@RequestBody EvaluateRequest req) {
54 return evaluator.evaluate(req.flagKey(), new EvaluationContext(
55 req.userId(), req.email(), req.plan(), "", Map.of()));
56 }
57}
58 

Actuator Health Indicator

java
1@Component
2@RequiredArgsConstructor
3public class FlagHealthIndicator implements HealthIndicator {
4 private final FlagEvaluator evaluator;
5 
6 @Override
7 public Health health() {
8 var flags = evaluator.getAllFlags();
9 if (flags.isEmpty()) {
10 return Health.down().withDetail("reason", "No flags loaded").build();
11 }
12 return Health.up()
13 .withDetail("flagCount", flags.size())
14 .withDetail("enabledCount", flags.values().stream()
15 .filter(FeatureFlag::isEnabled).count())
16 .build();
17 }
18}
19 

Testing

java
1@SpringBootTest
2class FlagEvaluatorTest {
3 @Autowired FlagEvaluator evaluator;
4 
5 @Test
6 void percentageRollout() {
7 evaluator.updateFlags(List.of(createFlag("test", true, 50)));
8 
9 long enabled = IntStream.range(0, 10_000)
10 .filter(i -> evaluator.isEnabled("test", new EvaluationContext("user-" + i)))
11 .count();
12 
13 assertThat(enabled / 100.0).isBetween(48.0, 52.0);
14 }
15 
16 @Test
17 void ruleTargeting() {
18 var flag = createFlag("enterprise", true, 0);
19 flag.setRules(List.of(new TargetingRule(
20 List.of(new Condition("plan", "in", List.of("enterprise"))),
21 "full", 1
22 )));
23 evaluator.updateFlags(List.of(flag));
24 
25 assertThat(evaluator.isEnabled("enterprise",
26 new EvaluationContext("1", "", "enterprise", "", Map.of()))).isTrue();
27 assertThat(evaluator.isEnabled("enterprise",
28 new EvaluationContext("2", "", "free", "", Map.of()))).isFalse();
29 }
30}
31 
32@WebMvcTest(FlagController.class)
33class FlagControllerTest {
34 @Autowired MockMvc mockMvc;
35 @MockBean FlagRepository flagRepo;
36 @MockBean FlagEvaluator evaluator;
37 @MockBean FlagSyncScheduler syncScheduler;
38 
39 @Test
40 void evaluateEndpoint() throws Exception {
41 when(evaluator.evaluate(eq("test"), any()))
42 .thenReturn(EvaluationResult.enabled("test", "v1", "default"));
43 
44 mockMvc.perform(post("/api/flags/evaluate")
45 .contentType(MediaType.APPLICATION_JSON)
46 .content("""
47 {"flagKey": "test", "userId": "user-1"}
48 """))
49 .andExpect(status().isOk())
50 .andExpect(jsonPath("$.enabled").value(true))
51 .andExpect(jsonPath("$.variant").value("v1"));
52 }
53}
54 

Conclusion

Spring Boot provides a comprehensive foundation for feature flag services. The AOP-based @FeatureGate annotation keeps endpoint gating declarative, JPA handles persistence with JSONB support for flexible targeting rules, and Spring's scheduling module manages background sync without external dependencies. The actuator health indicator provides production visibility into the flag system's state.

This implementation scales from a single-service flag system to a centralized flag management platform. The evaluation engine (in-memory, volatile map) handles millions of evaluations per second. The management API provides CRUD operations with immediate cache refresh. For larger deployments, add Redis caching between the database and evaluation layer, and implement webhook-based push updates for latency-critical flag changes.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026