Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Simon (Sai)
Simon (Sai)

Posted on

     

A Stack of Feature Flags [Tutorial]

An Introduction & Getting Started

After working on another project that implemented feature flags, I decided to create a guide that shows one way of implementing feature flags for a full-stack web application using Starlette and React + React Router. This guide can serve as a foundation for adding support to feature flags to an existing project.

The purpose of this tutorial is to show you an example of possibly implementing feature flags in a full stack application. The techniques shown here demonstrate one of the many ways to implement feature flags. As with everything, there are multiple ways to solve problems

To ensure the smoothest possible experience while following along with this tutorial, we will assume that you have the following installed and ready to go

✅ NodeJS 18/20+ and NPM
✅ Python 3.10+
✅ Pipenv (or virtualenv)
✅ Git

Application Specifics

The API for this project is implemented as a Starlette web application.

The frontend is built using React 18 and React Router 6. There will be an update to React 19 and React Router 7 in the near future.

Project Overview

To get started, start by cloning the starter repository from and navigating to the directory. You should see the following high-level folder structure

https://github.com/saiforceone/fstack-feature-flags

📁 fstack-backend

📁 fstack-frontend

Setting up the backend

Navigate to the backend folder, and initialize the virtual environment using the following command

# initialize environmentpipenv shell
Enter fullscreen modeExit fullscreen mode

Next, we’ll install our dependencies using the following

# install dependenciespipenvinstall
Enter fullscreen modeExit fullscreen mode

Finally, we’ll run our backend using the following command

uvicorn app:app--port 5101--reload
Enter fullscreen modeExit fullscreen mode

--port 5101 specifies that we want to run on local port5101 but you can change this to anything you want as long as there are no conflicts

--reload enables reload on changes

Testing the backend

Let’s do a quick test of our backend to make sure things are working. To do so, we can use a browser or http client like Postman or Insomnia and navigating tohttp://localhost:5101/api/notes. When we do this, we should see output that looks like the following because we don’t have any notes yet.

{data:null,message:"",success:false}
Enter fullscreen modeExit fullscreen mode

Setting up the Frontend

Let’s shift our focus to getting the frontend working. We’ll start by navigating to thefrontend folder and installing our dependencies with NPM.

npm i# ornpminstall
Enter fullscreen modeExit fullscreen mode

Creating our.env file

We will be making using of an environment file to store certain app-specific values. Let’s go ahead and create the following filefrontend/.env and paste the following

# Base URL for the APIVITE_API_BASE_URL="http://localhost:5101/api"
Enter fullscreen modeExit fullscreen mode

We can now run our frontend by using the following command

npm run dev
Enter fullscreen modeExit fullscreen mode

For this project, we are using Tailwindcss which has already been set up for us and is ready to use.

Let’s check out our project by navigating to the following url

http://localhost:5102
Enter fullscreen modeExit fullscreen mode

We should see something that looks like the image below

Image description

This should take care of all the set up part of the project. Next, we’ll get onto the actually work of implementing feature flags on both the backend and frontend

Implementing Feature Flags

Let’s go ahead and create a new branch for repository and naming it accordingly. I’ll be naming my branchimplement-feature-flags but you can call it whatever you want

git checkout-b implement-feature-flags
Enter fullscreen modeExit fullscreen mode

Defining The Feature Flag Model

In thebackend/models folder, we’ll create a new file calledfeature_flag.py and add the following code

# File: feature_flag.pyfromsqlalchemyimportString,Text,Booleanfromsqlalchemy.ormimportMapped,mapped_columnfrom.base_modelimportBaseModelclassFeatureFlag(BaseModel):__tablename__='feature_flag'id:Mapped[int]=mapped_column(primary_key=True,autoincrement=True)flag:Mapped[str]=mapped_column(String(100),nullable=False)description:Mapped[str]=mapped_column(Text,nullable=True)enabled:Mapped[bool]=mapped_column(Boolean,default=True)defto_dict(self):return{"flag":self.flag,"description":self.description,"enabled":self.enabled,}
Enter fullscreen modeExit fullscreen mode

Implementing the Feature Flag Controller

in thebackend/controllers folder, we’ll create a new file calledfeature_flag_controller.py and add the following code

# File: feature_flags_controller.pyimportsqlalchemyfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefromstarlette.typesimportScope,Receive,Sendfromcontrollers.base_controllerimportBaseControllerfrommodels.base_modelimportBaseModelfrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utilsclassFeatureFlagsController(BaseController):def__init__(self,scope:Scope,receive:Receive,send:Send):super().__init__(scope,receive,send)self.session=db_utils.session# check if the table exists and creates it if necessaryifnotsqlalchemy.inspect(db_utils.engine).has_table('feature_flag'):BaseModel.metadata.create_all(bind=db_utils.engine,tables=[BaseModel.metadata.tables['feature_flag']])asyncdefget(self,request:Request)->JSONResponse:response=FeatureFlagsController._build_response()# try to retrieve feature flags that are enabledtry:feature_flags=self.session.query(FeatureFlag).where(FeatureFlag.enabled).all()exceptExceptionase:print(f"Failed to retrieve feature flags with error:{e}")response['message']='An unexpected error occurred while retrieving feature flags'returnJSONResponse(response,status_code=500)iflen(feature_flags)==0:response['message']='No feature are available'returnJSONResponse(response,status_code=404)response['data']=[flag.to_dict()forflaginfeature_flags]response['success']=TruereturnJSONResponse(response)
Enter fullscreen modeExit fullscreen mode

Let’s also update ourcontrollers/__init__.py file as by addingfrom .feature_flags_controller import FeatureFlagsController to end of file as shown below

# File: controllers/__init__.pyfrom.notes_controllerimportNotesControllerfrom.feature_flags_controllerimportFeatureFlagsController# <- add this line
Enter fullscreen modeExit fullscreen mode

Next, we’ll update our routes so that we can fetch our features

# File: routes/__init__.pyfromstarlette.routingimportRoute,MountfromcontrollersimportNotesController,FeatureFlagsController# <- update our import to include FeatureFlagControllerapp_routes:list[Route|Mount]=[Mount('/api',routes=[Route('/notes',NotesController),Route('/notes/{note}',NotesController),Route('/features',FeatureFlagsController),# <- add this line]),]
Enter fullscreen modeExit fullscreen mode

Let’s test what we have so far by navigating tohttp://localhost:5101/api/features and seeing what is returned.

{data:null,message:"No feature are available",success:false}
Enter fullscreen modeExit fullscreen mode

This is normal since we have not yet added any features. Using a database client, we can manually add a feature or two. So feel free to do that, I’ll be adding two features,FE_INLINE_NOTE_DELETE andNOTE_DELETE but you can call them whatever you want. We should now see something that looks like the result below

{data:[{flag:"FE_INLINE_NOTE_DELETE",description:"Enables inline note deletion from the note listing"},{flag:"NOTE_DELETE",description:"Enables note deletion"}],message:"",success:true}
Enter fullscreen modeExit fullscreen mode

Note: we’re excluding theid andenabled fields since we don’t need to return theid of the specific feature and the feature is returned then it must beenabled.

We’ll come back to the backend to apply this to ourNotesController.

Updating the Frontend to use the features

So we have a feature flags endpoint that gives us a list of enabled features, let’s see about using this on the frontend

Updating frontend type definitions

Add the following type to ourfrontend/@types/fstack-flags.d.ts file

exporttypeFeatureFlag={/**   * @readonly   *   * A string representing the name of the flag   *   * @example `RENDER_NEW_UI`   */readonlyflag:string;/**   * @readonly   *   * An optional description for the feature flag   *   * @example 'Indicates if the new UI should be used instead of the current'   */readonlydescription?:string;};
Enter fullscreen modeExit fullscreen mode

In the same file, we will need to update ourFStackFlagsContext to include an array of feature flags

exporttypeFStackFlagsContext={/**   * Indicates if data is loading at the app-level   */dataLoading:boolean;/**   * Contains feature flags retrieved from the API   */featureFlags:Array<FeatureFlag>;// <- add this};
Enter fullscreen modeExit fullscreen mode

Implementing the FeatureFlagsServices

We will need a way to retrieve features from the API. Let’s create the filefrontend/services/feature-flags-service.ts and paste in the following code

// File : services/feature-flags-service.tsimport{typeAPIUtility,BaseAPIResponse,FeatureFlag}from"../@types/fstack-flags";constBASE_URL=import.meta.env.VITE_API_BASE_URL;exportdefaultclassFeatureFlagsServiceimplementsAPIUtility{readonlybaseUrl:string;constructor(){this.baseUrl=`${BASE_URL}/features`;}headers():Headers{returnnewHeaders({'content-type':'application/json',})}/**   * @function getFeatures   * @description retrieves the available features from the API   * @returns {Promise<Array<FeatureFlag> | null>}   */asyncgetFeatures():Promise<Array<FeatureFlag>|null>{try{constresponse=awaitfetch(this.baseUrl,{headers:this.headers(),})constjsonData=(awaitresponse.json())asBaseAPIResponse;if(!jsonData.success)returnnull;returnjsonData.dataasFeatureFlag[];}catch(e){console.error(`Failed to retrieve feature flags with error:${(easError).message}`);returnnull;}}}
Enter fullscreen modeExit fullscreen mode

ℹ️ As an extra step, we could add inbound validation using something like Zod to add that extra layer of safety at runtime.

Updating our context to retrieve feature flags

Now that we have our feature flags service, we will need to fetch enabled features and make them available via ourcontext. The following code will take care of that

import{createContext,ReactNode,useCallback,useEffect,useState}from"react";importtype{FeatureFlag,FStackFlagsContext}from"../@types/fstack-flags";importFeatureFlagsServicefrom"../services/feature-flags-service.ts";exportconstFStackFEContext=createContext<FStackFlagsContext|null>(null);exportdefaultfunctionFStackFeContextProvider({children}:{children:ReactNode}):ReactNode{const[dataLoading,setDataLoading]=useState<boolean>(false);const[features,setFeatures]=useState<Array<FeatureFlag>>([]);constfetchFeatures=useCallback(()=>{const_exec=async()=>{constfeatureFlagsService=newFeatureFlagsService();constfeatureResponse=awaitfeatureFlagsService.getFeatures();if(!featureResponse)return;setFeatures(featureResponse);}_exec().then();},[]);useEffect(()=>{fetchFeatures();},[fetchFeatures])return<FStackFEContext.Providervalue={{dataLoading,featureFlags:features}}>{children}</FStackFEContext.Provider>;}
Enter fullscreen modeExit fullscreen mode

If everything worked as expected, you should be able to see our context containing our feature flags similar to the screenshot below

Image description

Setting up conditional rendering based on a feature flag

With our feature flags being retrieved from the API and available via context, we will need a way to make use of them in our application. We will be creating a component that will handle things for us.

Let’s create our component calledfeature-wrapper.tsx in ourcomponents/shared directory and paste the following code

// File:  feature-wrapper.tsximport{typeReactNode,useContext}from"react";import{FStackFEContext}from"../../context/fstack-fe-context.tsx";import{FStackFlagsContext}from"../../@types/fstack-flags";import{hasFeature}from"../../helpers/feature-helpers.ts";typeFeatureWrapperProps={children:ReactNode;requiredFeature:string;}/** * @function FeatureWrapper * @param {ReactNode} children the component to render if the required feature flag is present * @param {string} requiredFeature the flag that is required to render the wrapped component * @constructor * @return {ReactNode} */exportdefaultfunctionFeatureWrapper({children,requiredFeature}:FeatureWrapperProps):ReactNode{const{featureFlags}=useContext(FStackFEContext)asFStackFlagsContext;constcanRender=hasFeature(requiredFeature,featureFlags);returncanRender?children:null;}
Enter fullscreen modeExit fullscreen mode

In order to test this out, let’s open our notes listingroutes/notes/_index.tsx and update how we are displaying notes as shown below:

// add state for selected noteconst[selectedNote,setSelectedNote]=useState<Note|null>(null);// add this to delete a noteconstdeleteNote=useCallback(()=>{const_exec=async()=>{if(!selectedNote)return;constnotesService=newNotesService();constdeleteResult=awaitnotesService.deleteNote(`${selectedNote.id}`);if(!deleteResult.success)returnalert("Failed to delete note");fetchNotes();};_exec().then();},[fetchNotes,selectedNote]);
Enter fullscreen modeExit fullscreen mode
// updated note card renedered<NoteCardkey={`note-${note.id}`}note={note}actionElements={<><NavLinkclassName='underline text-blue-600 flex items-center gap-1'to={`/edit-note/${note.id}`}><BiSolidEdit/>        Edit Note</NavLink>{/* Begin - add this block */}<FeatureWrapperrequiredFeature='FE_INLINE_NOTE_DELETE'><buttonclassName='flex items-center gap-1 text-red-600 underline cursor-pointer'onClick={()=>setSelectedNote(note)}><BiSolidTrash/>          Delete</button></FeatureWrapper>{/* End - add this block */}</>}/>
Enter fullscreen modeExit fullscreen mode
{/* add this confirm dialog as the last component in the _index.tsx component right above the closing </PageWrapper> tag*/}{selectedNote?(<ConfirmDialogcontent={<pclassName='text-lg'>        You are about to delete the note:{""}<spanclassName='font-medium'>{selectedNote.title}</span>. Would you like to continue?</p>}dialogDismissAction={()=>setSelectedNote(null)}dialogOpen={true}titleText='Delete Note?'dialogActionElements={<divclassName='flex items-center gap-2'><buttonclassName='p-2 rounded bg-slate-800 text-white flex items-center'onClick={()=>setSelectedNote(null)}><BiSolidArrowToLeft/>          No, don't delete it</button><buttonclassName='p-2 rounded bg-red-600 text-white flex items-center'onClick={()=>{setSelectedNote(null);deleteNote();}}><BiSolidTrash/>          Yes, delete it!</button></div>}/>):null}
Enter fullscreen modeExit fullscreen mode

Updating the API to facilitate feature flags

we have one thing left to do in order to complete our feature flag implementation which is implementing and using a new decorator function for feature flags. In the backend project, create the filesupport/require_feature_flag.py and paste the following code

importfunctoolsimportinspectimporttypingfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefrommodels.feature_flagimportFeatureFlagfrom.db_utilsimportdb_utilsdefrequire_feature_flag(required_flag:str):deffeature_exec_wrapper(func:typing.Callable)->typing.Callable:sig=inspect.signature(func)foridx,parameterinenumerate(sig.parameters.values()):ifparameter.name=="request":breakelse:raiseException(f'No"request" argument on function"{func}"')@functools.wraps(func)asyncdefexec_feature_check(*args,**kwargs):# try to retrieve feature flags matching the required_flagtry:feature_flag=db_utils.session.query(FeatureFlag).where(FeatureFlag.flag==required_flag,FeatureFlag.enabled==True).scalar()exceptExceptionase:print(f"failed to retrieve feature flag with error:{e}")returnJSONResponse({'success':False,'message':'Unexpected error occurred'},status_code=500)iffeature_flagisNone:print(f"the feature:{required_flag} was not found or is invalid")returnJSONResponse({'success':False,'message':'Invalid feature'},status_code=500)returnawaitfunc(*args,**kwargs)returnexec_feature_checkreturnfeature_exec_wrapper
Enter fullscreen modeExit fullscreen mode

Next, we’ll need to update thedelete method handler in ournotes_controller.py to make use of our newly created decorator function. Copy and paste the code below

# update importsfromsupport.require_feature_flagimportrequire_feature_flag# <- add this import# add decorator to delete method handler@require_feature_flag(required_flag='NOTE_DELETE')# <- add this above the delete method handler
Enter fullscreen modeExit fullscreen mode

With these changes in place, you should be able to enable features for both the frontend and backend part of your application. Play around with enabling and disabling feature flags and observe how the application behaves.

Where to go from here?

This tutorial is meant to give you an example of how you can implement feature flags in a fullstack web application and is not the only method. There are different ways to solve this problem, I would recommend experimentation with some of the other methods such as environment variables, hosted config files, SaaS applications, etc.

Implementing a route cache system (optional)

In this section, we will look at one possible way we can implement caching for a more robust solution. Since we’re using Starlette, we can tap into Starlette’slifespan event to load the enabled feature flags into application state.

Let’s take a look at some documentation and see how we can implement a simple application cache that will hold our enabled features. See:https://www.starlette.io/applications/#starlette.applications.Starlette

Update the imports in ourapp.py file

fromcontextlibimportasynccontextmanagerfrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utils
Enter fullscreen modeExit fullscreen mode

We’re going to implement a lifespan method inside of ourapp.py file as shown below

@asynccontextmanagerasyncdeflifespan(application):"""    This method performs tasks for application startup and shutdown. In this case, we are caching the enabled feature    flags on application startup, if none are found, we'll set the cached flags to an empty list    :param application:    :return:"""try:enabled_features=db_utils.session.query(FeatureFlag).where(FeatureFlag.enabled).all()setattr(application.state,'CACHED_FLAGS',[flag.to_dict()forflaginenabled_features])exceptExceptionase:print(f"failed to retrieve enabled feature flags with error:{e}. Continuing application startup...")setattr(application.state,'CACHED_FLAGS',[])yieldapp=Starlette(debug=True,middleware=middleware,routes=routes,lifespan=lifespan)
Enter fullscreen modeExit fullscreen mode

Next, we’ll update our decorator function to make use of our cached feature flags while having a fallback mode if something unexpected happens

importfunctoolsimportinspectimporttypingfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utilsdefrequire_feature_flag(required_flag:str):deffeature_exec_wrapper(func:typing.Callable)->typing.Callable:sig=inspect.signature(func)foridx,parameterinenumerate(sig.parameters.values()):ifparameter.name=="request":breakelse:raiseException(f'No"request" argument on function"{func}"')@functools.wraps(func)asyncdefexec_feature_check(*args,**kwargs):request=kwargs.get("request",args[idx]ifidx<len(args)elseNone)# <- retrieve the request from kwargsassertisinstance(request,Request)# <- assert request is an instance of Request# retrieve feature flags from the application statecached_flags=getattr(request.app.state,'CACHED_FLAGS',[])feature_flag=None# check the list of cached_flagsiflen(cached_flags)>0:# search the cacheforflagincached_flags:ifflag['flag']==required_flag:print("cache hit!")feature_flag=flagelse:print("cache miss")# try to retrieve feature flags matching the required_flagtry:feature_flag=db_utils.session.query(FeatureFlag).where(FeatureFlag.flag==required_flag,FeatureFlag.enabled==True).scalar()exceptExceptionase:print(f"failed to retrieve feature flag with error:{e}")returnJSONResponse({'success':False,'message':'Unexpected error occurred'},status_code=500)iffeature_flagisNone:print(f"the feature:{required_flag} was not found or is invalid")returnJSONResponse({'success':False,'message':'Invalid feature'},status_code=500)returnawaitfunc(*args,**kwargs)returnexec_feature_checkreturnfeature_exec_wrapper
Enter fullscreen modeExit fullscreen mode

Things to consider

For a more complete implementation, we would need to implement a way to invalidate and refresh the cached feature flags. One thing that comes to mind is using triggers or events provided by an ORM for a specific database model. In theory, when a new feature flag is added or an existing flag is updated, the cached feature flags can be refreshed and the application state will be consistent.

Therequire_feature_flag wrapper function could be further optimized / cleaned up

We can also implement a better logger system and other optimizations.

Anyway, this is intended as an example of how one could possibly implement feature flags for both the backend and frontend.

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

I'm Simon but you can me Sai. I write code live on twitch and sometimes I pretend to be a UI designer. You can find me on twitch: twitch.tv/saiforceone
  • Location
    Canada
  • Work
    I work as a FullStack Engineer (FE-Heavy) by day and livestream coding on Twitch by night.
  • Joined

More fromSimon (Sai)

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp