This commit is contained in:
Gayathri-PR 2026-02-08 23:36:20 +05:30 committed by GitHub
commit daa7e51326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 521 additions and 0 deletions

View file

@ -172,3 +172,74 @@ For additional details, please refer to the blog post [Hello DCO, Goodbye CLA: S
## License
The Spring PetClinic sample application is released under version 2.0 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0).
# PetClinic Feature Flags
- This project adds feature flags to the PetClinic app so that we can control the features to certain users.
# How it works:
- Enable/Disable:
Each feature can be enabled/disabled for the users based on if they are whitelisted or blacklisted. There is also a global disability which shuts down the specified feature until enabled.
- Whitelist:
Only users listed in the whitelist can access the feature specified.
- Blacklist:
Users listed in the blacklist will be blocked from the feature even if the feature is enabled.
- Rollout Percentage:
You can release a feature gradually instead of a global release. For example, if rollout_percentage = 50, roughly 50% of users get access (deterministic by user ID).
# Example:
| Feature Key | Enabled | Blacklist | Whitelist | Rollout % |
| ------------ | ------- | --------- | --------- | --------- |
| ADD_PET | true | 4 | 1 | 100 |
| OWNER_SEARCH | true | - | - | 50 |
Owner with id 4 is blacklisted i.e. they cannot add pets.
Owner with id 1 is whiteliste i.e. they can addpets.
The rollout percentage of OWNER_SEARCH key is 50% i.e. roughly half the users can search for owners.
# Using Feature Flags in Code:
@FeatureFlagEnabled("ADD_PET")
@GetMapping("/owners/{ownerId}/pets/new")
public String newPetForm(@PathVariable int ownerId) {
return "pets/createPetForm";
}
The Aspect intercepts the method and checks if the feature i.e. ADD_PET is:
1. Enabled
2. If user is whitelisted/blacklisted
3. Rollout percentage
If access is denied, the user gets HTTP 403 Forbidden.
# Database Setup:
- Tables
CREATE TABLE feature_flags (
id INT AUTO_INCREMENT PRIMARY KEY,
flag_key VARCHAR(100) UNIQUE,
is_enabled BOOLEAN,
globally_disabled BOOLEAN,
description VARCHAR(255),
rollout_percentage INT
);
CREATE TABLE feature_flag_blacklist (
feature_flag_id INT,
owner_id INT
);
CREATE TABLE feature_flag_whitelist (
feature_flag_id INT,
owner_id INT
);
- Example Data
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description, rollout_percentage)
VALUES ('ADD_PET', true, false, 'Allows adding pets', 100);
INSERT INTO feature_flag_blacklist (feature_flag_id, owner_id) VALUES (1, 4);
INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (1, 1);
# Why Feature Flags?
- Roll out features gradually.
- Access control i.e. privilege to users.
- Feature blocking whenever needed i.e. internal errors or data corruption.
- Avoid frequent code redeployment for feature changes.

View file

@ -0,0 +1,135 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.model;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity
@Table(name = "feature_flags")
public class FeatureFlag extends BaseEntity {
@Column(name = "flag_key", unique = true)
@NotBlank
private String key;
@Column(name = "is_enabled")
private boolean isEnabled;
@Column(name = "globally_disabled")
private boolean globallyDisabled;
@Column(name = "rollout_percentage")
private Integer rolloutPercentage;
@Column(name = "description")
private String description;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "feature_flag_blacklist", joinColumns = @JoinColumn(name = "feature_flag_id"))
@JsonIgnore
@Column(name = "owner_id")
private List<Integer> blacklistedUsers = new ArrayList<>();
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "feature_flag_whitelist", joinColumns = @JoinColumn(name = "feature_flag_id"))
@JsonIgnore
@Column(name = "owner_id")
private List<Integer> whitelistedUsers = new ArrayList<>();
public boolean isAllowedForOwner(Integer ownerId) {
if (globallyDisabled) {
return false;
}
if (ownerId != null && blacklistedUsers.contains(ownerId)) {
return false;
}
if (ownerId != null && whitelistedUsers.contains(ownerId)) {
return true;
}
if (rolloutPercentage != null && ownerId != null) {
int bucket = Math.abs(ownerId.hashCode() % 100);
return bucket < rolloutPercentage;
}
return isEnabled;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public boolean isEnabled() {
return isEnabled;
}
public void setEnabled(boolean enabled) {
this.isEnabled = enabled;
}
public boolean isGloballyDisabled() {
return globallyDisabled;
}
public void setGloballyDisabled(boolean globallyDisabled) {
this.globallyDisabled = globallyDisabled;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@JsonIgnore
public List<Integer> getBlacklistedUsers() {
return blacklistedUsers;
}
public void setBlacklistedUsers(List<Integer> blacklistedUsers) {
this.blacklistedUsers = blacklistedUsers;
}
@JsonIgnore
public List<Integer> getWhitelistedUsers() {
return whitelistedUsers;
}
public void setWhitelistedUsers(List<Integer> whitelistedUsers) {
this.whitelistedUsers = whitelistedUsers;
}
}

View file

@ -20,6 +20,7 @@ import java.util.Objects;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.samples.petclinic.system.FeatureFlagEnabled;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
@ -69,11 +70,13 @@ class OwnerController {
+ ". Please ensure the ID is correct " + "and the owner exists in the database."));
}
@FeatureFlagEnabled("OWNER_ADD")
@GetMapping("/owners/new")
public String initCreationForm() {
return VIEWS_OWNER_CREATE_OR_UPDATE_FORM;
}
@FeatureFlagEnabled("OWNER_ADD")
@PostMapping("/owners/new")
public String processCreationForm(@Valid Owner owner, BindingResult result, RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
@ -92,6 +95,7 @@ class OwnerController {
}
@GetMapping("/owners")
@FeatureFlagEnabled("OWNER_SEARCH")
public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result,
Model model) {
// allow parameterless GET request for /owners to return all records
@ -133,11 +137,13 @@ class OwnerController {
return owners.findByLastNameStartingWith(lastname, pageable);
}
@FeatureFlagEnabled("OWNER_EDIT")
@GetMapping("/owners/{ownerId}/edit")
public String initUpdateOwnerForm() {
return VIEWS_OWNER_CREATE_OR_UPDATE_FORM;
}
@FeatureFlagEnabled("OWNER_EDIT")
@PostMapping("/owners/{ownerId}/edit")
public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @PathVariable("ownerId") int ownerId,
RedirectAttributes redirectAttributes) {

View file

@ -21,6 +21,7 @@ import java.util.Objects;
import java.util.Optional;
import org.springframework.stereotype.Controller;
import org.springframework.samples.petclinic.system.FeatureFlagEnabled;
import org.springframework.ui.ModelMap;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@ -95,6 +96,7 @@ class PetController {
dataBinder.setValidator(new PetValidator());
}
@FeatureFlagEnabled("ADD_PET")
@GetMapping("/pets/new")
public String initCreationForm(Owner owner, ModelMap model) {
Pet pet = new Pet();
@ -103,6 +105,7 @@ class PetController {
}
@PostMapping("/pets/new")
@FeatureFlagEnabled("ADD_PET")
public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result,
RedirectAttributes redirectAttributes) {
@ -124,11 +127,13 @@ class PetController {
return "redirect:/owners/{ownerId}";
}
@FeatureFlagEnabled("EDIT_PET")
@GetMapping("/pets/{petId}/edit")
public String initUpdateForm() {
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
}
@FeatureFlagEnabled("EDIT_PET")
@PostMapping("/pets/{petId}/edit")
public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult result,
RedirectAttributes redirectAttributes) {

View file

@ -19,6 +19,7 @@ import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Controller;
import org.springframework.samples.petclinic.system.FeatureFlagEnabled;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
@ -89,6 +90,7 @@ class VisitController {
// Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is
// called
@PostMapping("/owners/{ownerId}/pets/{petId}/visits/new")
@FeatureFlagEnabled("ADD_VISIT")
public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int petId, @Valid Visit visit,
BindingResult result, RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {

View file

@ -0,0 +1,68 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.system;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Aspect
@Component
public class FeatureFlagAspect {
private final FeatureFlagHelper featureFlagHelper;
private final HttpServletRequest request;
public FeatureFlagAspect(FeatureFlagHelper featureFlagHelper, HttpServletRequest request) {
this.featureFlagHelper = featureFlagHelper;
this.request = request;
}
@Around("@annotation(featureFlagEnabled)")
public Object checkFeatureFlag(ProceedingJoinPoint joinPoint, FeatureFlagEnabled featureFlagEnabled)
throws Throwable {
String featureFlagKey = featureFlagEnabled.value();
Integer ownerId = extractOwnerIdFromPath();
boolean enabled = featureFlagHelper.isEnabled(featureFlagKey, ownerId);
if (!enabled) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Feature '" + featureFlagKey + "' is disabled");
}
return joinPoint.proceed();
}
private Integer extractOwnerIdFromPath() {
String uri = request.getRequestURI();
String[] parts = uri.split("/");
for (int i = 0; i < parts.length - 1; i++) {
if ("owners".equals(parts[i])) {
try {
return Integer.parseInt(parts[i + 1]);
}
catch (NumberFormatException e) {
return null;
}
}
}
return null;
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.system;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.samples.petclinic.model.FeatureFlag;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/feature-flags")
public class FeatureFlagController {
private final FeatureFlagRepository featureFlagRepository;
public FeatureFlagController(FeatureFlagRepository featureFlagRepository) {
this.featureFlagRepository = featureFlagRepository;
}
@GetMapping
public ResponseEntity<List<FeatureFlag>> getAllFlags() {
List<FeatureFlag> flags = (List<FeatureFlag>) featureFlagRepository.findAll();
return ResponseEntity.ok(flags);
}
@PostMapping
public ResponseEntity<FeatureFlag> createFlag(@RequestBody FeatureFlag featureFlag) {
FeatureFlag saved = featureFlagRepository.save(featureFlag);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@PutMapping("/{id}")
public ResponseEntity<FeatureFlag> updateFlag(@PathVariable Integer id, @RequestBody FeatureFlag featureFlag) {
if (!featureFlagRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
featureFlag.setId(id);
FeatureFlag updated = featureFlagRepository.save(featureFlag);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteFlag(@PathVariable Integer id) {
if (!featureFlagRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
featureFlagRepository.findById(id).ifPresent(flag -> {
flag.getBlacklistedUsers().clear();
flag.getWhitelistedUsers().clear();
featureFlagRepository.delete(flag);
});
return ResponseEntity.noContent().build();
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.system;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to restrict access to a method based on a feature flag.
*
* If the feature flag is disabled or globally disabled, the method will not be executed.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FeatureFlagEnabled {
/**
* The key of the feature flag to check.
*/
String value();
}

View file

@ -0,0 +1,40 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.system;
import org.springframework.samples.petclinic.model.FeatureFlag;
import org.springframework.stereotype.Component;
@Component
public class FeatureFlagHelper {
private final FeatureFlagRepository featureFlagRepository;
public FeatureFlagHelper(FeatureFlagRepository featureFlagRepository) {
this.featureFlagRepository = featureFlagRepository;
}
public boolean isEnabled(String flagKey, Integer userId) {
FeatureFlag flag = featureFlagRepository.findByKey(flagKey);
if (flag == null) {
return false; // fail-safe default
}
return flag.isAllowedForOwner(userId);
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.samples.petclinic.system;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.samples.petclinic.model.FeatureFlag;
public interface FeatureFlagRepository extends JpaRepository<FeatureFlag, Integer> {
FeatureFlag findByKey(String key);
}

View file

@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.vet;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.samples.petclinic.system.FeatureFlagEnabled;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
@ -41,6 +42,7 @@ class VetController {
this.vetRepository = vetRepository;
}
@FeatureFlagEnabled("VIEW_VETS")
@GetMapping("/vets.html")
public String showVetList(@RequestParam(defaultValue = "1") int page, Model model) {
// Here we are returning an object of type 'Vets' rather than a collection of Vet
@ -66,6 +68,7 @@ class VetController {
return vetRepository.findAll(pageable);
}
@FeatureFlagEnabled("VIEW_VETS")
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
// Here we are returning an object of type 'Vets' rather than a collection of Vet

View file

@ -51,3 +51,18 @@ INSERT INTO visits VALUES (default, 7, '2013-01-01', 'rabies shot');
INSERT INTO visits VALUES (default, 8, '2013-01-02', 'rabies shot');
INSERT INTO visits VALUES (default, 8, '2013-01-03', 'neutered');
INSERT INTO visits VALUES (default, 7, '2013-01-04', 'spayed');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('ADD_PET', false, false, 'Allows adding new pets');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('EDIT_PET', true, false, 'Allows editing existing pets');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('ADD_VISIT', true, false, 'Allows adding new visits');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_SEARCH', true, false, 'Allows searching for owners');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_EDIT', true, false, 'Allows editing owner information');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('OWNER_ADD', true, false, 'Allows adding new owners');
INSERT INTO feature_flags (flag_key, is_enabled, globally_disabled, description) VALUES ('VIEW_VETS', true, false, 'Allows viewing vet information');
-- id 5 can't edit owners
INSERT INTO feature_flag_blacklist (feature_flag_id, owner_id) VALUES (1, 5);
-- only id 1 can add pets and owners
INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (1, 1);
INSERT INTO feature_flag_whitelist (feature_flag_id, owner_id) VALUES (6, 1);

View file

@ -5,6 +5,8 @@ DROP TABLE visits IF EXISTS;
DROP TABLE pets IF EXISTS;
DROP TABLE types IF EXISTS;
DROP TABLE owners IF EXISTS;
DROP TABLE feature_flags IF EXISTS;
DROP TABLE feature_flag_blacklist IF EXISTS;
CREATE TABLE vets (
@ -62,3 +64,36 @@ CREATE TABLE visits (
);
ALTER TABLE visits ADD CONSTRAINT fk_visits_pets FOREIGN KEY (pet_id) REFERENCES pets (id);
CREATE INDEX visits_pet_id ON visits (pet_id);
CREATE TABLE feature_flags (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
flag_key VARCHAR(100) NOT NULL,
is_enabled BOOLEAN,
globally_disabled BOOLEAN,
rollout_percentage INTEGER,
description VARCHAR(255)
);
CREATE INDEX idx_feature_flags_key ON feature_flags(flag_key);
CREATE TABLE feature_flag_blacklist (
feature_flag_id INTEGER NOT NULL,
owner_id INTEGER NOT NULL,
CONSTRAINT fk_feature_blacklist_flag
FOREIGN KEY (feature_flag_id)
REFERENCES feature_flags(id),
CONSTRAINT fk_blacklist_owner
FOREIGN KEY (owner_id)
REFERENCES owners(id)
);
CREATE TABLE feature_flag_whitelist (
feature_flag_id INTEGER NOT NULL,
owner_id INTEGER NOT NULL,
CONSTRAINT fk_feature_whitelist_flag
FOREIGN KEY (feature_flag_id)
REFERENCES feature_flags(id),
CONSTRAINT fk_whitelist_owner
FOREIGN KEY (owner_id)
REFERENCES owners(id)
);