DEV Community

Cover image for Part 2: Kill Switches and Scheduled Flags — Feature Flags in Production (Java)
David Frigolet
David Frigolet

Posted on

Part 2: Kill Switches and Scheduled Flags — Feature Flags in Production (Java)

Part 2 of Feature Flags from Scratch. Part 1 built a feature flag system with rollout percentages and targeting rules on top of PostgreSQL. This post adds two production-critical capabilities: a kill switch that force-disables a flag instantly regardless of its current state, and scheduled activation so flags turn on and off automatically at a specified time.

It's Tuesday afternoon. A flag you shipped last week — currently at 40% rollout — is causing errors for users in that cohort. Your monitoring fires. You need to turn it off.

The system from Part 1 can do this: set enabled: false. But there's a problem. You have to know the current state, write the right payload, and wait for the normal update path. In a real incident, that's friction you don't want.

What you actually need is a big red button: force-disable this flag, right now, regardless of what state it's in. One field. Evaluated before everything else. Overrides all rules and rollout percentages.

And the other gap: you can't schedule anything. Enabling a flag for a Black Friday sale means someone has to be awake at midnight to toggle it. That's the second thing we're building.

What we're adding

Two new capabilities on top of the system from Part 1:

  • Kill switchforce_disabled field that short-circuits flag evaluation instantly
  • Scheduled activationactivate_at and deactivate_at timestamps — flags that know when to turn on and off

New API surface:

PUT  /flags/{name}  → now accepts forceDisabled, activateAt, deactivateAt
Enter fullscreen mode Exit fullscreen mode

A quick preview of what the kill switch looks like:

# Flag at 40% rollout — user-001 is in the cohort
curl "http://localhost:8080/flags/evaluate/payment-v2?userId=user-001"
# → {"enabled":true,"reason":"in rollout bucket 8 < 40%"}

# Something's wrong. Kill switch.
curl -X PUT http://localhost:8080/flags/payment-v2 \
  -H "Content-Type: application/json" \
  -d '{"forceDisabled":true}'

# Same user, same bucket — kill switch overrides everything
curl "http://localhost:8080/flags/evaluate/payment-v2?userId=user-001"
# → {"enabled":false,"reason":"kill switch active"}
Enter fullscreen mode Exit fullscreen mode

We'll cover:

  • How to short-circuit evaluation with a single boolean field
  • The evaluation priority stack: what overrides what
  • Why adding NOT NULL DEFAULT FALSE to a live table is safe in PostgreSQL 11+
  • How to schedule a flag window with two nullable timestamp columns

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


Flamingock — open-source change-as-code

We're adding two more schema migrations in this post — the kill switch column and the scheduled activation columns. Same pattern as Part 1: each change is a @Change class, applied once at startup, tracked automatically.

If you haven't starred the project yet:

Star Flamingock on GitHub


Continuing from Part 1

This is Part 2 of Feature Flags from Scratch. We're building on the same Spring Boot + PostgreSQL system from Part 1.

Part 2 is a separate projectfeature-flags-kill-switch — not an extension of the feature-flags directory. If you're starting here, clone and run the Part 1 sample first. The project setup (build.gradle, Docker Compose, Flamingock config) is identical — Part 2 adds on top of that foundation.

If you built along in Part 1: add the new classes and update the existing ones as shown.


Kill switch

The evaluation logic in EvaluationService currently has three levels: flag disabled → targeting rules → rollout percentage. We're adding a zeroth level that runs before all of them.

Entity update

Add one field to FeatureFlag:

// FeatureFlag.java — add alongside the existing fields

@Column(name = "force_disabled")
private boolean forceDisabled = false;

public boolean isForceDisabled() { return forceDisabled; }

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

Building from scratch? Here's the complete entity with all fields (including the scheduled activation fields we'll add next):

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 = "rollout_percentage")
    private int rolloutPercentage = 100;

    @Column(name = "force_disabled")    // ← new in Part 2
    private boolean forceDisabled = false;

    @Column(name = "activate_at")       // ← new in Part 2
    private Instant activateAt;

    @Column(name = "deactivate_at")     // ← new in Part 2
    private Instant deactivateAt;

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

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

    protected FeatureFlag() {}

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

    public String getName() { return name; }
    public String getDescription() { return description; }

    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
        this.updatedAt = Instant.now();
    }

    public int getRolloutPercentage() { return rolloutPercentage; }
    public void setRolloutPercentage(int rolloutPercentage) {
        this.rolloutPercentage = rolloutPercentage;
        this.updatedAt = Instant.now();
    }

    public boolean isForceDisabled() { return forceDisabled; }
    public void setForceDisabled(boolean forceDisabled) {
        this.forceDisabled = forceDisabled;
        this.updatedAt = Instant.now();
    }

    public Instant getActivateAt() { return activateAt; }
    public void setActivateAt(Instant activateAt) {
        this.activateAt = activateAt;
        this.updatedAt = Instant.now();
    }

    public Instant getDeactivateAt() { return deactivateAt; }
    public void setDeactivateAt(Instant deactivateAt) {
        this.deactivateAt = deactivateAt;
        this.updatedAt = Instant.now();
    }

    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}
Enter fullscreen mode Exit fullscreen mode

Evaluation update

Add this as the first check in EvaluationService.evaluate(), before the enabled check:

// EvaluationService.java — first line inside evaluate(), before everything else

if (flag.isForceDisabled()) {
    return new EvalResult(false, "kill switch active");
}
Enter fullscreen mode Exit fullscreen mode

The full evaluation priority stack is now:

1. force_disabled = true?   → false  (kill switch — overrides everything)
2. enabled = false?         → false  (flag is off)
3. targeting rule matches?  → true   (targeted user — bypasses rollout)
4. in rollout bucket?       → deterministic hash result
Enter fullscreen mode Exit fullscreen mode

We'll extend this stack further when we add scheduled activation.

Building from scratch? Here's the complete EvaluationService.java after both features are added — see the Scheduled flags section below for the full version.

Controller update

Extending from Part 1? This snippet renames the flagRepository field to repository. Update the field declaration and constructor parameter in your existing FlagController.java to match, otherwise the code below won't compile.

Update the request record and handler:

// FlagController.java — update the record and handler

record UpdateFlagRequest(Boolean enabled, Integer rolloutPercentage, Boolean forceDisabled,
                         Instant activateAt, Instant deactivateAt) {}

@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());
    if (req.forceDisabled() != null) flag.setForceDisabled(req.forceDisabled());
    if (req.activateAt() != null) flag.setActivateAt(req.activateAt());
    if (req.deactivateAt() != null) flag.setDeactivateAt(req.deactivateAt());
    return repository.save(flag);
}
Enter fullscreen mode Exit fullscreen mode

DB schema evolution — add force_disabled

One NOT NULL DEFAULT FALSE boolean column added to feature_flags. Flamingock applies this migration once at startup and records it in the audit log — it will never run again.

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-kill-switch", author = "dev")
public class _0004__AddKillSwitch {

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

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

Why NOT NULL DEFAULT FALSE instead of nullable? A kill switch with a NULL state is ambiguous — does NULL mean "not set" or "off"? NOT NULL DEFAULT FALSE keeps the semantics unambiguous: every flag has an explicit kill switch state. In PostgreSQL 11+, adding a NOT NULL DEFAULT column to an existing table is a metadata-only operation — it doesn't rewrite the table, so it's safe on large datasets. Flamingock applies this once at startup and never runs it again.

Try it

# Create and configure a flag
curl -s -X POST http://localhost:8080/flags \
  -H "Content-Type: application/json" \
  -d '{"name":"payment-v2","description":"New payment flow"}' | jq

curl -s -X PUT http://localhost:8080/flags/payment-v2 \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"rolloutPercentage":40}' | jq

# user-001 is in the rollout cohort
curl -s "http://localhost:8080/flags/evaluate/payment-v2?userId=user-001" | jq
# → {"enabled":true,"reason":"in rollout bucket 8 < 40%"}

# Incident. Flip the kill switch.
curl -s -X PUT http://localhost:8080/flags/payment-v2 \
  -H "Content-Type: application/json" \
  -d '{"forceDisabled":true}' | jq

# Kill switch overrides the rollout — user is now excluded
curl -s "http://localhost:8080/flags/evaluate/payment-v2?userId=user-001" | jq
# → {"enabled":false,"reason":"kill switch active"}

# Targeting rules are also overridden
curl -s "http://localhost:8080/flags/evaluate/payment-v2?userId=user-001&plan=pro" | jq
# → {"enabled":false,"reason":"kill switch active"}
Enter fullscreen mode Exit fullscreen mode

The flag is still "enabled" with rolloutPercentage: 40 and any targeting rules still in place. The kill switch doesn't change any of that — it just short-circuits the evaluation result. When you turn it off, everything resumes exactly as it was.


Scheduled flags

Right now, enabling a flag for a scheduled event means someone has to be online to toggle it at exactly the right moment. We're adding activate_at and deactivate_at timestamps — a flag can know its own window, and evaluation respects it automatically.

Entity update

Add two nullable timestamp fields to FeatureFlag:

// FeatureFlag.java — add alongside the existing fields

@Column(name = "activate_at")
private Instant activateAt;

@Column(name = "deactivate_at")
private Instant deactivateAt;

public Instant getActivateAt() { return activateAt; }
public void setActivateAt(Instant activateAt) {
    this.activateAt = activateAt;
    this.updatedAt = Instant.now();
}

public Instant getDeactivateAt() { return deactivateAt; }
public void setDeactivateAt(Instant deactivateAt) {
    this.deactivateAt = deactivateAt;
    this.updatedAt = Instant.now();
}
Enter fullscreen mode Exit fullscreen mode

Both fields are nullable — null means "no constraint". A flag with activateAt = null and deactivateAt = null behaves exactly as before.

Evaluation update

Add the schedule checks after the enabled check and before targeting rules:

// EvaluationService.java — add after the enabled check

Instant now = Instant.now();
if (flag.getActivateAt() != null && now.isBefore(flag.getActivateAt())) {
    return new EvalResult(false, "not yet active");
}
if (flag.getDeactivateAt() != null && now.isAfter(flag.getDeactivateAt())) {
    return new EvalResult(false, "schedule expired");
}
Enter fullscreen mode Exit fullscreen mode

The complete evaluation priority stack is now:

1. force_disabled = true?        → false  (kill switch — overrides everything)
2. enabled = false?              → false  (flag is off)
3. now < activate_at?            → false  (not yet active)
4. now > deactivate_at?          → false  (schedule expired)
5. targeting rule matches?       → true   (targeted user — bypasses rollout)
6. in rollout bucket?            → deterministic hash result
Enter fullscreen mode Exit fullscreen mode

Building from scratch? Here's the complete EvaluationService.java:

package io.flamingock.flags.service;

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

import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

@Service
public class EvaluationService {

    public record EvalResult(boolean enabled, String reason) {}

    private final FlagRepository flagRepository;
    private final TargetingRuleRepository ruleRepository;

    public EvaluationService(FlagRepository flagRepository, TargetingRuleRepository ruleRepository) {
        this.flagRepository = flagRepository;
        this.ruleRepository = 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.isForceDisabled()) {
            return new EvalResult(false, "kill switch active");
        }

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

        Instant now = Instant.now();
        if (flag.getActivateAt() != null && now.isBefore(flag.getActivateAt())) {
            return new EvalResult(false, "not yet active");
        }
        if (flag.getDeactivateAt() != null && now.isAfter(flag.getDeactivateAt())) {
            return new EvalResult(false, "schedule expired");
        }

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

        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(attrValue::equals);
            case "starts_with" -> attrValue.startsWith(rule.getValue());
            default            -> false;
        };
    }

    private int bucket(String flagName, String userId) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest((flagName + ":" + userId).getBytes());
            return Math.floorMod(ByteBuffer.wrap(hash).getInt(), 100);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

DB schema evolution — add activate_at and deactivate_at

Two nullable TIMESTAMPTZ columns added to feature_flags. Flamingock applies this migration once at startup and records it in the audit log — it will never run again.

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-scheduled-activation", author = "dev")
public class _0005__AddScheduledActivation {

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

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

Five migrations total now. Each one corresponds to a feature decision — the schema is a historical record of what the system can do and when each capability was added.

Try it

# Schedule a flag for a Black Friday window
curl -s -X POST http://localhost:8080/flags \
  -H "Content-Type: application/json" \
  -d '{"name":"black-friday-deal","description":"Black Friday sale"}' | jq

curl -s -X PUT http://localhost:8080/flags/black-friday-deal \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"activateAt":"2025-11-28T00:00:00Z","deactivateAt":"2025-11-29T00:00:00Z"}' | jq

# Before the window — flag is enabled but not yet active
curl -s "http://localhost:8080/flags/evaluate/black-friday-deal?userId=user-001" | jq
# → {"enabled":false,"reason":"not yet active"}

# After the window closes
curl -s "http://localhost:8080/flags/evaluate/black-friday-deal?userId=user-001" | jq
# → {"enabled":false,"reason":"schedule expired"}

# Combining: schedule a flag and protect it with a kill switch
curl -s -X PUT http://localhost:8080/flags/black-friday-deal \
  -H "Content-Type: application/json" \
  -d '{"forceDisabled":true}' | jq
# → Kill switch takes priority — flag is off even if the window is open
Enter fullscreen mode Exit fullscreen mode

The flag never needs someone online to toggle it. Set the window once, and evaluation handles the rest. The kill switch still works as expected — it overrides the schedule entirely.


Final project structure

feature-flags-kill-switch/
├── build.gradle
├── settings.gradle
├── Dockerfile
├── docker-compose.yml
└── src/main/java/io/flamingock/flags/
    ├── FeatureFlagApplication.java
    ├── config/
    │   └── FlamingockConfig.java
    ├── controller/
    │   └── FlagController.java              ← updated
    ├── service/
    │   └── EvaluationService.java           ← updated (kill switch + schedule checks)
    ├── model/
    │   ├── FeatureFlag.java                 ← updated (force_disabled, activate_at, deactivate_at)
    │   └── TargetingRule.java
    ├── repository/
    │   ├── FlagRepository.java
    │   └── TargetingRuleRepository.java
    └── changes/
        ├── _0001__CreateFlagsTable.java
        ├── _0002__AddRolloutPercentage.java
        ├── _0003__CreateTargetingRules.java
        ├── _0004__AddKillSwitch.java        ← new
        └── _0005__AddScheduledActivation.java  ← new
Enter fullscreen mode Exit fullscreen mode

Full source

Clone it, run it, break it: feature-flags-kill-switch

Schema evolution powered by Flamingock — open-source change-as-code for Java.

If the approach resonated, ⭐ star the project on GitHub https://github.com/flamingock/flamingock-java.

Top comments (2)

Collapse
 
dieppa profile image
Dieppa

Nice exntesion on the previous article. These two new features,a re quite important for a basic feature flag, when you don't need a big and fancy tool. Many of the Project I have been part of, would have appreciated.

Nice one!

Collapse
 
dhivesh profile image
Dhivesh

Fantastic views on how to implement this whilst evolving changes safely! Thank you so much for sharing. The series is starting to look strong!