Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions feature-flags/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
.idea/
*.iml
*.ipr
*.iws
out/
.settings/
.classpath
.project
bin/
1 change: 1 addition & 0 deletions feature-flags/.sdkmanrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
java=21-tem
9 changes: 9 additions & 0 deletions feature-flags/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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"]
119 changes: 119 additions & 0 deletions feature-flags/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Feature Flags with Flamingock + Spring Boot + PostgreSQL

A working example that builds a feature-flag service from scratch using [Flamingock](https://www.flamingock.io) to manage database schema evolution, Spring Boot for the REST API, and PostgreSQL as the backing store.

## Prerequisites

- Java 21 (use `sdk env` if you have [SDKMAN](https://sdkman.io/) installed)
- Docker & Docker Compose

## Quick Start

```bash
docker compose up --build
```

That's it. Postgres starts first (health-checked), then the app boots, Flamingock runs the migrations, and the API is live at `http://localhost:8080`.

### Running locally (without Docker for the app)

```bash
# Start only Postgres
docker compose up db -d

# Run the app
./gradlew bootRun
```

## API

### Create a flag

```bash
curl -s -X POST localhost:8080/flags \
-H "Content-Type: application/json" \
-d '{"name":"dark-mode","description":"Dark mode UI"}'
```

### List all flags

```bash
curl -s localhost:8080/flags
```

### Update a flag (enable + set rollout %)

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

### Evaluate a flag for a user

```bash
curl -s "localhost:8080/flags/evaluate/dark-mode?userId=user-42"
```

Evaluation is deterministic — the same `userId` always lands in the same rollout bucket (SHA-256 hash).

### Add a targeting rule

```bash
curl -s -X POST localhost:8080/flags/dark-mode/rules \
-H "Content-Type: application/json" \
-d '{"attribute":"plan","operator":"equals","value":"pro"}'
```

### Evaluate with attributes

```bash
curl -s "localhost:8080/flags/evaluate/dark-mode?userId=user-999&plan=pro"
```

When a targeting rule matches, the flag is enabled regardless of rollout percentage.

### List rules for a flag

```bash
curl -s localhost:8080/flags/dark-mode/rules
```

## How Flamingock manages the schema

Instead of `ddl-auto` or hand-written SQL scripts, Flamingock applies versioned, auditable changes at startup:

| Change | What it does |
|--------|-------------|
| `_0001__CreateFlagsTable` | Creates the `feature_flags` table |
| `_0002__AddRolloutPercentage` | Adds the `rollout_percentage` column |
| `_0003__CreateTargetingRules` | Creates the `targeting_rules` table + index |

Each change targets the `postgres-flags` SQL target system and receives a `java.sql.Connection` automatically. Flamingock tracks execution in its audit store so changes run exactly once, even across restarts.

## Targeting rule operators

| Operator | Behaviour |
|----------|-----------|
| `equals` | Exact string match |
| `contains` | Substring match |
| `in` | Comma-separated list membership |
| `starts_with` | Prefix match |

## Project structure

```
feature-flags/
├── docker-compose.yml
├── Dockerfile
├── build.gradle
├── settings.gradle
└── src/main/java/com/example/flags/
├── FeatureFlagApplication.java # @EnableFlamingock entry point
├── config/FlamingockConfig.java # SqlTargetSystem + audit store beans
├── changes/ # Flamingock migrations
├── model/ # JPA entities
├── repository/ # Spring Data repositories
├── service/EvaluationService.java # Flag evaluation logic
└── controller/FlagController.java # REST API
```
37 changes: 37 additions & 0 deletions feature-flags/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'io.flamingock' version '1.0.0'
}

flamingock {
community()
springboot()
}

java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenLocal()
mavenCentral()
}

group = 'com.example'
version = '1.0-SNAPSHOT'

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

tasks.withType(JavaCompile).configureEach {
options.compilerArgs.add('-parameters')
}
31 changes: 31 additions & 0 deletions feature-flags/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
volumes:
pgdata:

services:
db:
image: postgres:16
environment:
POSTGRES_DB: flags
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/flags
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: postgres
depends_on:
db:
condition: service_healthy
Binary file added feature-flags/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
6 changes: 6 additions & 0 deletions feature-flags/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
2 changes: 2 additions & 0 deletions feature-flags/gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 92 additions & 0 deletions feature-flags/gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions feature-flags/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
}
}

rootProject.name = 'feature-flags'
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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");
}
}
}
Loading