DEV Community

Cover image for How Feature Flag Rollouts Actually Work (Build One from Scratch in Java)
Dieppa
Dieppa

Posted on • Originally published at flamingock.io

How Feature Flag Rollouts Actually Work (Build One from Scratch in Java)

I needed feature flags for a side project — not a SaaS dashboard, just three things: toggle a feature, roll it out to 10% of users, and target specific customers.

So I built a thin version that works at scale in production.

You can clone it and run it with Docker Compose, or build it step by step. Either way, you'll understand what's happening under the hood — the same mechanics that LaunchDarkly, Unleash, and Flagsmith use internally.

What we're building

A REST API with three capabilities:

  • Toggle flags on/off globally
  • Gradual rollout — enable for 10% of users, then 50%, then everyone
  • Target specific users — all "pro" plan users, specific countries, etc.

Here's what it looks like:

# Create a flag, set it to 30% rollout, and evaluate
curl -X POST http://localhost:8080/flags -H "Content-Type: application/json" \
  -d '{"name":"dark-mode","description":"Dark mode UI"}'

curl -X PUT http://localhost:8080/flags/dark-mode -H "Content-Type: application/json" \
  -d '{"enabled":true,"rolloutPercentage":30}'

curl "http://localhost:8080/flags/evaluate/dark-mode?userId=user-42"
# → {"enabled":true,"reason":"in rollout bucket 12 < 30%"}

curl "http://localhost:8080/flags/evaluate/dark-mode?userId=user-99"
# → {"enabled":false,"reason":"outside rollout bucket 73 >= 30%"}
Enter fullscreen mode Exit fullscreen mode

Same user always gets the same result. That's deterministic hashing — we'll get to how it works.

POST   /flags                  → create a flag
PUT    /flags/{name}           → toggle / configure
GET    /flags/evaluate/{name}  → check if enabled for a user
POST   /flags/{name}/rules     → add targeting rule
GET    /flags/{name}/rules     → list targeting rules
Enter fullscreen mode Exit fullscreen mode

You'll learn:

  • Deterministic cohort assignment for percentage rollouts
  • A data model for rollouts + targeting rules
  • Schema evolution managed as versioned code

Stack: Spring Boot 3, PostgreSQL 16, Flamingock (for schema evolution), Docker Compose.

Time: ~30 minutes if you're comfortable with Spring Boot, or just clone & run.


Flamingock — open-source change-as-code

Quick background on the tool handling schema evolution in this project. Flamingock is an open-source platform for managing how external systems evolve — databases, queues, configs, schemas — as versioned code applied/verified at startup.

If you find the approach useful, a ⭐️ Star to our Flamingock project on GitHub would help us grow.

Star Flamingock on GitHub

Star Flamingock on GitHub


Setup

Option A: Clone and run

git clone https://github.com/flamingock/flamingock-java-samples
cd flamingock-java-samples/feature-flags
docker compose up --build
Enter fullscreen mode Exit fullscreen mode

App starts at localhost:8080. Done.

Option B: Build from scratch

build.gradle:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.4'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'io.flamingock' version '1.0.0'
}

group = 'io.flamingock'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

flamingock {
    community()
    springboot()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'org.postgresql:postgresql'
}
Enter fullscreen mode Exit fullscreen mode

settings.gradle:

pluginManagement {
    repositories {
        mavenLocal()
        gradlePluginPortal()
    }
}

rootProject.name = 'feature-flags'
Enter fullscreen mode Exit fullscreen mode

Generate the Gradle wrapper (run once from the project root):

gradle wrapper --gradle-version 8.12
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: flags
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - db
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/flags

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

Dockerfile:

FROM gradle:8-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle bootJar --no-daemon

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

application.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/flags
    username: postgres
    password: postgres
  jpa:
    hibernate:
      ddl-auto: none  # Flamingock handles all schema changes
Enter fullscreen mode Exit fullscreen mode

FeatureFlagApplication.java — just a standard Spring Boot entry point:

package io.flamingock.flags;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FeatureFlagApplication {
    public static void main(String[] args) {
        SpringApplication.run(FeatureFlagApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's build.


Part 1 — Simple flags

The simplest possible thing: a flag with a name and an on/off switch.

Entity

package io.flamingock.flags.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;

@Entity
@Table(name = "feature_flags")
public class FeatureFlag {

    @Id
    private String name;
    private String description;
    private boolean enabled;

    @Column(name = "created_at")
    private Instant createdAt;

    @Column(name = "updated_at")
    private Instant updatedAt;

    public FeatureFlag() {}

    public FeatureFlag(String name, String description) {
        this.name = name;
        this.description = description;
        this.enabled = false;
        this.createdAt = Instant.now();
        this.updatedAt = Instant.now();
    }

    public String getName() { return name; }
    public String getDescription() { return description; }
    public boolean isEnabled() { return enabled; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
        this.updatedAt = Instant.now();
    }
}
Enter fullscreen mode Exit fullscreen mode

Repository

package io.flamingock.flags.repository;

import io.flamingock.flags.model.FeatureFlag;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FlagRepository extends JpaRepository<FeatureFlag, String> {}
Enter fullscreen mode Exit fullscreen mode

Controller

package io.flamingock.flags.controller;

import io.flamingock.flags.model.FeatureFlag;
import io.flamingock.flags.repository.FlagRepository;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/flags")
public class FlagController {

    private final FlagRepository repository;

    public FlagController(FlagRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public FeatureFlag create(@RequestBody CreateFlagRequest req) {
        return repository.save(new FeatureFlag(req.name(), req.description()));
    }

    @GetMapping
    public List<FeatureFlag> list() {
        return repository.findAll();
    }

    @PutMapping("/{name}")
    public FeatureFlag toggle(
            @PathVariable String name,
            @RequestBody Map<String, Boolean> body) {
        FeatureFlag flag = repository.findById(name).orElseThrow();
        flag.setEnabled(body.get("enabled"));
        return repository.save(flag);
    }

    record CreateFlagRequest(String name, String description) {}
}
Enter fullscreen mode Exit fullscreen mode

That's the feature flag logic — straightforward. But who creates the feature_flags table?

DB schema evolution

We'll evolve this schema three times as the flag system grows. Each change is versioned, applied automatically at startup, and has a rollback. Flamingock handles this — same pattern whether you're changing SQL, config, or any other external system.

Changes are annotated with @Change and @Apply. The @TargetSystem annotation tells Flamingock which external system this change targets — here it's our SQL database. Flamingock injects a Connection that's already part of a managed transaction, so each change is atomic.

package io.flamingock.flags.changes;

import io.flamingock.api.annotations.Apply;
import io.flamingock.api.annotations.Change;
import io.flamingock.api.annotations.Rollback;
import io.flamingock.api.annotations.TargetSystem;

import java.sql.Connection;
import java.sql.Statement;

@TargetSystem(id = "postgres-flags")
@Change(id = "create-flags-table", author = "dev")
public class _0001__CreateFlagsTable {

    @Apply
    public void apply(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("""
                    CREATE TABLE IF NOT EXISTS feature_flags (
                        name         VARCHAR(255) PRIMARY KEY,
                        description  TEXT,
                        enabled      BOOLEAN DEFAULT FALSE,
                        created_at   TIMESTAMPTZ DEFAULT NOW(),
                        updated_at   TIMESTAMPTZ DEFAULT NOW()
                    )
                    """);
        }
    }

    @Rollback
    public void rollback(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("DROP TABLE IF EXISTS feature_flags");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The _0001__ prefix is a naming convention — Flamingock applies changes in order, so the numbering keeps them sequential.

updated_at is set to NOW() on insert; our Java entity updates it explicitly on every change (see setEnabled(), setRolloutPercentage()).

Now wire up Flamingock. All the configuration lives in one place — the @EnableFlamingock annotation, the target system, and the audit store that tracks which changes have been applied:

package io.flamingock.flags.config;

import io.flamingock.api.annotations.EnableFlamingock;
import io.flamingock.api.annotations.Stage;
import io.flamingock.internal.core.external.store.CommunityAuditStore;
import io.flamingock.store.sql.SqlAuditStore;
import io.flamingock.targetsystem.sql.SqlTargetSystem;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@EnableFlamingock(
    stages = @Stage(location = "io.flamingock.flags.changes")
)
@Configuration
public class FlamingockConfig {

    @Bean
    public SqlTargetSystem sqlTargetSystem(DataSource dataSource) {
        return new SqlTargetSystem("postgres-flags", dataSource);
    }

    @Bean
    public CommunityAuditStore auditStore(SqlTargetSystem sqlTargetSystem) {
        return SqlAuditStore.from(sqlTargetSystem);
    }
}
Enter fullscreen mode Exit fullscreen mode

Try it

# Create a flag
curl -s -X POST http://localhost:8080/flags \
  -H "Content-Type: application/json" \
  -d '{"name":"dark-mode","description":"Enable dark mode UI"}' | jq

# Toggle on
curl -s -X PUT http://localhost:8080/flags/dark-mode \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}' | jq
Enter fullscreen mode Exit fullscreen mode

Working feature flags. But right now it's all-or-nothing: on for everyone, or off for everyone. What if we want to roll out to 10% of users first?


Part 2 — Percentage rollout (this is the fun part)

We need two things:

  1. A rollout_percentage field on the flag
  2. A way to assign users to cohorts consistently — same user, same result every time

Entity update

Add the field to FeatureFlag:

@Column(name = "rollout_percentage")
private int rolloutPercentage = 100;

public int getRolloutPercentage() { return rolloutPercentage; }
public void setRolloutPercentage(int pct) {
    this.rolloutPercentage = pct;
    this.updatedAt = Instant.now();
}
Enter fullscreen mode Exit fullscreen mode

Controller update

@PutMapping("/{name}")
public FeatureFlag update(
        @PathVariable String name,
        @RequestBody UpdateFlagRequest req) {
    FeatureFlag flag = repository.findById(name).orElseThrow();
    if (req.enabled() != null) flag.setEnabled(req.enabled());
    if (req.rolloutPercentage() != null) flag.setRolloutPercentage(req.rolloutPercentage());
    return repository.save(flag);
}

record UpdateFlagRequest(Boolean enabled, Integer rolloutPercentage) {}
Enter fullscreen mode Exit fullscreen mode

Evaluation service

Here's the trick: hash the flag name + user ID. Same input → same hash → same cohort assignment. This is how LaunchDarkly, Unleash, and Flagsmith all do it.

package io.flamingock.flags.service;

import io.flamingock.flags.model.FeatureFlag;
import io.flamingock.flags.repository.FlagRepository;
import org.springframework.stereotype.Service;

import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

@Service
public class EvaluationService {

    private final FlagRepository flagRepository;

    public EvaluationService(FlagRepository flagRepository) {
        this.flagRepository = flagRepository;
    }

    public EvalResult evaluate(String flagName, String userId) {
        FeatureFlag flag = flagRepository.findById(flagName).orElse(null);
        if (flag == null) {
            return new EvalResult(false, "flag not found");
        }

        if (!flag.isEnabled()) {
            return new EvalResult(false, "flag disabled");
        }

        if (flag.getRolloutPercentage() >= 100) {
            return new EvalResult(true, "rollout 100%");
        }

        int bucket = bucket(flagName, userId);
        boolean inRollout = bucket < flag.getRolloutPercentage();
        return new EvalResult(inRollout, inRollout
                ? "in rollout bucket " + bucket + " < " + flag.getRolloutPercentage() + "%"
                : "outside rollout bucket " + bucket + " >= " + flag.getRolloutPercentage() + "%");
    }

    /**
     * Deterministic cohort assignment using SHA-256.
     * Same user + flag always lands in the same bucket.
     */
    private int bucket(String flagName, String userId) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest((flagName + ":" + userId).getBytes());
            // First 4 bytes → integer → mod 100 → bucket 0–99
            return Math.floorMod(ByteBuffer.wrap(hash).getInt(), 100);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    public record EvalResult(boolean enabled, String reason) {}
}
Enter fullscreen mode Exit fullscreen mode

💡The key insight: Because the hash is deterministic, increasing the rollout from 30% to 50% adds new users without removing anyone who was already in. That's why this approach works for gradual rollouts — you're expanding the cohort, not reshuffling it.

Deterministic Cohort Assignment

DB schema evolution

package io.flamingock.flags.changes;

import io.flamingock.api.annotations.Apply;
import io.flamingock.api.annotations.Change;
import io.flamingock.api.annotations.Rollback;
import io.flamingock.api.annotations.TargetSystem;

import java.sql.Connection;
import java.sql.Statement;

@TargetSystem(id = "postgres-flags")
@Change(id = "add-rollout-percentage", author = "dev")
public class _0002__AddRolloutPercentage {

    @Apply
    public void apply(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute(
                "ALTER TABLE feature_flags ADD COLUMN IF NOT EXISTS rollout_percentage INT DEFAULT 100"
            );
        }
    }

    @Rollback
    public void rollback(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute(
                "ALTER TABLE feature_flags DROP COLUMN IF EXISTS rollout_percentage"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Second schema evolution — Flamingock tracks what's been applied and only runs new changes.

Add the endpoint:

// In FlagController

@GetMapping("/evaluate/{name}")
public EvaluationService.EvalResult evaluate(
        @PathVariable String name,
        @RequestParam String userId) {
    return evaluationService.evaluate(name, userId);
}
Enter fullscreen mode Exit fullscreen mode

Try it

# Set dark-mode to 30% rollout
curl -s -X PUT http://localhost:8080/flags/dark-mode \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"rolloutPercentage":30}' | jq

# Test different users — some will be in, some out
curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-001" | jq
# → {"enabled":true,"reason":"in rollout bucket 8 < 30%"}

curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-002" | jq
# → {"enabled":false,"reason":"outside rollout bucket 58 >= 30%"}

# Same user always gets the same result (deterministic)
curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-001" | jq
# → {"enabled":true,"reason":"in rollout bucket 8 < 30%"} ← always
Enter fullscreen mode Exit fullscreen mode

Now we can do gradual rollouts. But sometimes percentage isn't precise enough — "enable for all Pro plan users" or "enable in Germany only."


Part 3 — Targeting rules

A flag can have rules like plan equals pro or country in DE,FR,ES. If any rule matches, the user gets the feature regardless of rollout percentage.

Entity + Repository

package io.flamingock.flags.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "targeting_rules")
public class TargetingRule {

    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "flag_name")
    private String flagName;

    private String attribute;   // "plan", "country", "email"
    private String operator;    // "equals", "contains", "in"
    private String value;       // "pro", "DE,FR,ES", "@company.com"

    @Column(name = "created_at")
    private Instant createdAt;

    public TargetingRule() {}

    public TargetingRule(String flagName, String attribute, String operator, String value) {
        this.flagName = flagName;
        this.attribute = attribute;
        this.operator = operator;
        this.value = value;
        this.createdAt = Instant.now();
    }

    public UUID getId() { return id; }
    public String getFlagName() { return flagName; }
    public String getAttribute() { return attribute; }
    public String getOperator() { return operator; }
    public String getValue() { return value; }
}
Enter fullscreen mode Exit fullscreen mode
package io.flamingock.flags.repository;

import io.flamingock.flags.model.TargetingRule;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

public interface TargetingRuleRepository extends JpaRepository<TargetingRule, UUID> {
    List<TargetingRule> findByFlagName(String flagName);
}
Enter fullscreen mode Exit fullscreen mode

Updated evaluation

// Add to EvaluationService

private final TargetingRuleRepository ruleRepository;

public EvalResult evaluate(String flagName, String userId, Map<String, String> attrs) {
    FeatureFlag flag = flagRepository.findById(flagName).orElse(null);
    if (flag == null) {
        return new EvalResult(false, "flag not found");
    }

    if (!flag.isEnabled()) {
        return new EvalResult(false, "flag disabled");
    }

    // Targeting rules take priority
    List<TargetingRule> rules = ruleRepository.findByFlagName(flagName);
    for (TargetingRule rule : rules) {
        if (matches(rule, attrs)) {
            return new EvalResult(true, "targeting rule matched: " + rule.getAttribute() + " " + rule.getOperator() + " " + rule.getValue());
        }
    }

    // Fall back to percentage
    if (flag.getRolloutPercentage() >= 100) {
        return new EvalResult(true, "rollout 100%");
    }

    int bucket = bucket(flagName, userId);
    boolean inRollout = bucket < flag.getRolloutPercentage();
    return new EvalResult(inRollout, inRollout
            ? "in rollout bucket " + bucket + " < " + flag.getRolloutPercentage() + "%"
            : "outside rollout bucket " + bucket + " >= " + flag.getRolloutPercentage() + "%");
}

private boolean matches(TargetingRule rule, Map<String, String> attrs) {
    String attrValue = attrs.get(rule.getAttribute());
    if (attrValue == null) return false;

    return switch (rule.getOperator()) {
        case "equals"      -> attrValue.equals(rule.getValue());
        case "contains"    -> attrValue.contains(rule.getValue());
        case "in"          -> Arrays.stream(rule.getValue().split(","))
                                    .map(String::trim)
                                    .anyMatch(v -> v.equals(attrValue));
        case "starts_with" -> attrValue.startsWith(rule.getValue());
        default -> false;
    };
}
Enter fullscreen mode Exit fullscreen mode

Controller additions

@PostMapping("/{name}/rules")
public TargetingRule addRule(@PathVariable String name, @RequestBody AddRuleRequest req) {
    return ruleRepository.save(new TargetingRule(name, req.attribute(), req.operator(), req.value()));
}

@GetMapping("/{name}/rules")
public List<TargetingRule> listRules(@PathVariable String name) {
    return ruleRepository.findByFlagName(name);
}

@GetMapping("/evaluate/{name}")
public EvaluationService.EvalResult evaluate(
        @PathVariable String name,
        @RequestParam String userId,
        @RequestParam Map<String, String> allParams) {
    Map<String, String> attrs = new HashMap<>(allParams);
    attrs.remove("userId");
    return evaluationService.evaluate(name, userId, attrs);
}

record AddRuleRequest(String attribute, String operator, String value) {}
Enter fullscreen mode Exit fullscreen mode

DB schema evolution

package io.flamingock.flags.changes;

import io.flamingock.api.annotations.Apply;
import io.flamingock.api.annotations.Change;
import io.flamingock.api.annotations.Rollback;
import io.flamingock.api.annotations.TargetSystem;

import java.sql.Connection;
import java.sql.Statement;

@TargetSystem(id = "postgres-flags")
@Change(id = "create-targeting-rules", author = "dev")
public class _0003__CreateTargetingRules {

    @Apply
    public void apply(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"");
            stmt.execute("""
                    CREATE TABLE IF NOT EXISTS targeting_rules (
                        id         UUID DEFAULT gen_random_uuid() PRIMARY KEY,
                        flag_name  VARCHAR(255) REFERENCES feature_flags(name),
                        attribute  VARCHAR(255) NOT NULL,
                        operator   VARCHAR(50) NOT NULL,
                        value      TEXT NOT NULL,
                        created_at TIMESTAMPTZ DEFAULT NOW()
                    )
                    """);
            stmt.execute(
                "CREATE INDEX IF NOT EXISTS idx_targeting_rules_flag_name ON targeting_rules(flag_name)"
            );
        }
    }

    @Rollback
    public void rollback(Connection connection) throws Exception {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("DROP TABLE IF EXISTS targeting_rules");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The pgcrypto extension provides gen_random_uuid() for native UUID generation in Postgres.

That's three schema changes now, each versioned and applied automatically at startup. The schema has evolved alongside the application code — no manual scripts, no drift between what the app expects and what the database has.

Schema Evolution

Try it

# Enable dark-mode for all Pro users
curl -s -X POST http://localhost:8080/flags/dark-mode/rules \
  -H "Content-Type: application/json" \
  -d '{"attribute":"plan","operator":"equals","value":"pro"}' | jq

# Add rule: enable for European countries
curl -s -X POST http://localhost:8080/flags/dark-mode/rules \
  -H "Content-Type: application/json" \
  -d '{"attribute":"country","operator":"in","value":"DE,FR,ES,IT"}' | jq

# Pro user → always enabled (even if outside the 30% rollout)
curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-999&plan=pro" | jq
# → {"enabled":true,"reason":"targeting rule matched: plan equals pro"}

# User in Germany → matches country rule
curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-888&country=DE" | jq
# → {"enabled":true,"reason":"targeting rule matched: country in DE,FR,ES,IT"}

# Free user outside Europe → falls back to percentage rollout
curl -s "http://localhost:8080/flags/evaluate/dark-mode?userId=user-777&plan=free&country=US" | jq
# → {"enabled":false,"reason":"outside rollout bucket 61 >= 30%"}
Enter fullscreen mode Exit fullscreen mode

Final project structure

feature-flags/
├── build.gradle
├── settings.gradle
├── Dockerfile
├── docker-compose.yml
├── src/main/java/io/flamingock/flags/
│   ├── FeatureFlagApplication.java
│   ├── config/
│   │   └── FlamingockConfig.java
│   ├── controller/
│   │   └── FlagController.java
│   ├── service/
│   │   └── EvaluationService.java
│   ├── model/
│   │   ├── FeatureFlag.java
│   │   └── TargetingRule.java
│   ├── repository/
│   │   ├── FlagRepository.java
│   │   └── TargetingRuleRepository.java
│   └── changes/
│       ├── _0001__CreateFlagsTable.java
│       ├── _0002__AddRolloutPercentage.java
│       └── _0003__CreateTargetingRules.java
└── src/main/resources/
    └── application.yml
Enter fullscreen mode Exit fullscreen mode

What's next

This gives you feature flags with real rollout logic and targeting. A few directions to take it:

Audit trail — track who toggled what and when. Every flag change becomes a versioned event.

Environment-aware flags — flags that behave differently in dev, staging, and production. This is where managing the evolution of your flag configuration across environments gets interesting — and where the change-as-code approach starts to shine beyond just SQL.

Scheduled activationactivate_at / deactivate_at timestamps for time-boxed experiments.

I'll cover audit trails and environment-aware configs in the next post.


Full source

Clone it, run it, break it: https://github.com/flamingock/flamingock-java-samples/tree/master/feature-flags

The schema evolution in this project is powered by Flamingock, an open-source change-as-code platform.

⭐ Help us out!

If the approach resonated, star the project on GitHub , and let me also know in the comments

Star Flamingock on GitHub

https://github.com/flamingock/flamingock-java.

Top comments (4)

Collapse
 
dhivesh profile image
Dhivesh • Edited

I’ve always been curious about how gradual rollouts are actually implemented under the hood, and this was a great taster 👌

What I really liked is that it doesn’t treat feature flags as magic. Breaking down deterministic bucketing, percentage exposure, and kill switches makes it much clearer what’s actually happening during a rollout. It also subtly highlights one of the big shortcomings of managing flags purely in config files.

Once you start doing real rollouts, having flags persisted and controlled properly (instead of baked into static config) becomes a huge advantage. You gain operational control, safer rollbacks, and better coordination across environments.

Really solid breakdown, thanks for sharing.

Collapse
 
dieppa profile image
Dieppa

Thanks! I thought it’d be useful to write an article covering the basics, since I’ve been on projects where we didn’t need a full platform like LaunchDarkly or Flagsmith, but simple config properties in a file weren’t enough either

Collapse
 
david_frigolet_35ec54c7b2 profile image
David Frigolet

Excellent breakdown — really appreciate the clarity around percentage rollouts and deterministic evaluation.

I’m curious how you think about the evolution path of the feature flag system itself — not just individual flags, but the underlying structure and evaluation model behind them.

For example, when you introduce new targeting dimensions, change the rollout algorithm, or adjust how rules are evaluated, that’s effectively evolving the behavior of the system. In distributed environments, those structural changes don’t always get applied in the exact same order across staging and production. Even if the final schema or configuration looks identical, the path taken to get there can differ.

That can lead to subtle behavioural drift — especially if older flags were created under previous evaluation semantics or if data was written under earlier assumptions.

Do you have a strategy to make that evolution ordered and verifiable? For instance, do you version evaluation logic, treat structural changes as executable migrations, or ensure environments converge through an explicit evolution sequence rather than just matching state?

I’d be really interested in how you approach that over the long term as the system grows.

Collapse
 
dieppa profile image
Dieppa

I am glad to hear you are curios about that. In the next entries of the series I will cover those aspects.

Thanks