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 environment
pipenv shell
Enter fullscreen mode Exit fullscreen mode

Next, we’ll install our dependencies using the following

# install dependencies
pipenv install
Enter fullscreen mode Exit fullscreen mode

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

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

--port 5101 specifies that we want to run on local port 5101 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 to http://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 mode Exit fullscreen mode

Setting up the Frontend

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

npm i

# or
npm install
Enter fullscreen mode Exit 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 file frontend/.env and paste the following

# Base URL for the API
VITE_API_BASE_URL="http://localhost:5101/api"
Enter fullscreen mode Exit fullscreen mode

We can now run our frontend by using the following command

npm run dev
Enter fullscreen mode Exit 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 mode Exit 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 branch implement-feature-flags but you can call it whatever you want

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

Defining The Feature Flag Model

In the backend/models folder, we’ll create a new file called feature_flag.py and add the following code

# File: feature_flag.py
from sqlalchemy import String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column

from .base_model import BaseModel

class FeatureFlag(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)

    def to_dict(self):
        return {
            "flag": self.flag,
            "description": self.description,
            "enabled": self.enabled,
        }

Enter fullscreen mode Exit fullscreen mode

Implementing the Feature Flag Controller

in the backend/controllers folder, we’ll create a new file called feature_flag_controller.py and add the following code

# File: feature_flags_controller.py
import sqlalchemy
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import Scope, Receive, Send

from controllers.base_controller import BaseController
from models.base_model import BaseModel
from models.feature_flag import FeatureFlag
from support.db_utils import db_utils

class FeatureFlagsController(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 necessary
        if not sqlalchemy.inspect(db_utils.engine).has_table('feature_flag'):
            BaseModel.metadata.create_all(bind=db_utils.engine, tables=[BaseModel.metadata.tables['feature_flag']])

    async def get(self, request: Request) -> JSONResponse:
        response = FeatureFlagsController._build_response()

        # try to retrieve feature flags that are enabled
        try:
            feature_flags = self.session.query(
                FeatureFlag
            ).where(FeatureFlag.enabled).all()
        except Exception as e:
            print(f"Failed to retrieve feature flags with error: {e}")
            response['message'] = 'An unexpected error occurred while retrieving feature flags'
            return JSONResponse(response, status_code=500)

        if len(feature_flags) == 0:
            response['message'] = 'No feature are available'
            return JSONResponse(response, status_code=404)

        response['data'] = [flag.to_dict() for flag in feature_flags]
        response['success'] = True
        return JSONResponse(response)

Enter fullscreen mode Exit fullscreen mode

Let’s also update our controllers/__init__.py file as by adding from .feature_flags_controller import FeatureFlagsController to end of file as shown below

# File: controllers/__init__.py

from .notes_controller import NotesController
from .feature_flags_controller import FeatureFlagsController # <- add this line

Enter fullscreen mode Exit fullscreen mode

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

# File: routes/__init__.py
from starlette.routing import Route, Mount

from controllers import NotesController, FeatureFlagsController # <- update our import to include FeatureFlagController

app_routes: list[Route | Mount] = [
    Mount('/api', routes=[
        Route('/notes', NotesController),
        Route('/notes/{note}', NotesController),
        Route('/features', FeatureFlagsController), # <- add this line
    ]),
]

Enter fullscreen mode Exit fullscreen mode

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

{
    data: null,
    message: "No feature are available",
    success: false
}
Enter fullscreen mode Exit 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 and NOTE_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 mode Exit fullscreen mode

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

We’ll come back to the backend to apply this to our NotesController.

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 our frontend/@types/fstack-flags.d.ts file

export type FeatureFlag = {
  /**
   * @readonly
   *
   * A string representing the name of the flag
   *
   * @example `RENDER_NEW_UI`
   */
  readonly flag: string;

  /**
   * @readonly
   *
   * An optional description for the feature flag
   *
   * @example 'Indicates if the new UI should be used instead of the current'
   */
  readonly description?: string;
};
Enter fullscreen mode Exit fullscreen mode

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


export type FStackFlagsContext = {
  /**
   * 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 mode Exit fullscreen mode

Implementing the FeatureFlagsServices

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

// File : services/feature-flags-service.ts
import { type APIUtility, BaseAPIResponse, FeatureFlag } from "../@types/fstack-flags";

const BASE_URL = import.meta.env.VITE_API_BASE_URL;

export default class FeatureFlagsService implements APIUtility {
  readonly baseUrl: string;
  constructor() {
    this.baseUrl = `${BASE_URL}/features`;
  }

  headers(): Headers {
    return new Headers({
      'content-type': 'application/json',
    })
  }

  /**
   * @function getFeatures
   * @description retrieves the available features from the API
   * @returns {Promise<Array<FeatureFlag> | null>}
   */
  async getFeatures(): Promise<Array<FeatureFlag>|null> {
    try {
      const response = await fetch(this.baseUrl, {
        headers: this.headers(),
      })
      const jsonData = (await response.json()) as BaseAPIResponse;
      if (!jsonData.success) return null;
      return jsonData.data as FeatureFlag[];
    } catch (e) {
      console.error(`Failed to retrieve feature flags with error: ${(e as Error).message}`);
      return null;
    }
  }
}
Enter fullscreen mode Exit 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 our context. The following code will take care of that

import { createContext, ReactNode, useCallback, useEffect, useState } from "react";
import type { FeatureFlag, FStackFlagsContext } from "../@types/fstack-flags";
import FeatureFlagsService from "../services/feature-flags-service.ts";

export const FStackFEContext = createContext<FStackFlagsContext | null>(null);

export default function FStackFeContextProvider({ children }: { children: ReactNode }): ReactNode {
  const [dataLoading, setDataLoading] = useState<boolean>(false);
  const [features, setFeatures] = useState<Array<FeatureFlag>>([]);

  const fetchFeatures = useCallback(() => {
    const _exec = async () => {
      const featureFlagsService = new FeatureFlagsService();
      const featureResponse = await featureFlagsService.getFeatures();
      if (!featureResponse) return;
      setFeatures(featureResponse);
    }

    _exec().then();
  }, []);

  useEffect(() => {
    fetchFeatures();
  }, [fetchFeatures])

  return <FStackFEContext.Provider value={{ dataLoading, featureFlags: features }}>{children}</FStackFEContext.Provider>;
}

Enter fullscreen mode Exit 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 called feature-wrapper.tsx in our components/shared directory and paste the following code

// File:  feature-wrapper.tsx
import { type ReactNode, useContext } from "react";
import { FStackFEContext } from "../../context/fstack-fe-context.tsx";
import { FStackFlagsContext } from "../../@types/fstack-flags";
import { hasFeature } from "../../helpers/feature-helpers.ts";

type FeatureWrapperProps = {
  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}
 */
export default function FeatureWrapper({ children, requiredFeature }: FeatureWrapperProps): ReactNode {
  const { featureFlags } = useContext(FStackFEContext) as FStackFlagsContext;
  const canRender = hasFeature(requiredFeature, featureFlags);
  return canRender ? children : null;
}
Enter fullscreen mode Exit fullscreen mode

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

// add state for selected note
const [selectedNote, setSelectedNote] = useState<Note | null>(null);

// add this to delete a note
const deleteNote = useCallback(() => {
  const _exec = async () => {
    if (!selectedNote) return;
    const notesService = new NotesService();
    const deleteResult = await notesService.deleteNote(`${selectedNote.id}`);
    if (!deleteResult.success) return alert("Failed to delete note");
    fetchNotes();
  };

  _exec().then();
}, [fetchNotes, selectedNote]);
Enter fullscreen mode Exit fullscreen mode
// updated note card renedered

<NoteCard
  key={`note-${note.id}`}
  note={note}
  actionElements={
    <>
      <NavLink
        className='underline text-blue-600 flex items-center gap-1'
        to={`/edit-note/${note.id}`}
      >
        <BiSolidEdit />
        Edit Note
      </NavLink>
      {/* Begin - add this block */}
      <FeatureWrapper requiredFeature='FE_INLINE_NOTE_DELETE'>
        <button
          className='flex items-center gap-1 text-red-600 underline cursor-pointer'
          onClick={() => setSelectedNote(note)}
        >
          <BiSolidTrash />
          Delete
        </button>
      </FeatureWrapper>
      {/* End - add this block */}
    </>
  }
/>
Enter fullscreen mode Exit fullscreen mode
{/* add this confirm dialog as the last component in the _index.tsx component right above the closing </PageWrapper> tag*/}
{selectedNote ? (
  <ConfirmDialog
    content={
      <p className='text-lg'>
        You are about to delete the note:{" "}
        <span className='font-medium'>{selectedNote.title}</span>. Would you like to continue?
      </p>
    }
    dialogDismissAction={() => setSelectedNote(null)}
    dialogOpen={true}
    titleText='Delete Note?'
    dialogActionElements={
      <div className='flex items-center gap-2'>
        <button
          className='p-2 rounded bg-slate-800 text-white flex items-center'
          onClick={() => setSelectedNote(null)}
        >
          <BiSolidArrowToLeft />
          No, don't delete it
        </button>
        <button
          className='p-2 rounded bg-red-600 text-white flex items-center'
          onClick={() => {
            setSelectedNote(null);
            deleteNote();
          }}
        >
          <BiSolidTrash />
          Yes, delete it!
        </button>
      </div>
    }
  />
) : null}
Enter fullscreen mode Exit 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 file support/require_feature_flag.py and paste the following code

import functools
import inspect
import typing
from starlette.requests import Request
from starlette.responses import JSONResponse

from models.feature_flag import FeatureFlag
from .db_utils import db_utils

def require_feature_flag(required_flag: str):

    def feature_exec_wrapper(func: typing.Callable) -> typing.Callable:
        sig = inspect.signature(func)
        for idx, parameter in enumerate(sig.parameters.values()):
            if parameter.name == "request":
                break
        else:
            raise Exception(
                f'No "request" argument on function "{func}"'
            )

        @functools.wraps(func)
        async def exec_feature_check(*args, **kwargs):

            # try to retrieve feature flags matching the required_flag            
            try:
                feature_flag = db_utils.session.query(
                    FeatureFlag
                ).where(
                    FeatureFlag.flag == required_flag, FeatureFlag.enabled == True
                ).scalar()
            except Exception as e:
                print(f"failed to retrieve feature flag with error: {e}")
                return JSONResponse({
                    'success': False,
                    'message': 'Unexpected error occurred'
                }, status_code=500)

            if feature_flag is None:
                print(f"the feature: {required_flag} was not found or is invalid")
                return JSONResponse({
                    'success': False,
                    'message': 'Invalid feature'
                }, status_code=500)

            return await func(*args, **kwargs)

        return exec_feature_check

    return feature_exec_wrapper

Enter fullscreen mode Exit fullscreen mode

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

# update imports
from support.require_feature_flag import require_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 mode Exit 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’s lifespan 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 our app.py file

from contextlib import asynccontextmanager

from models.feature_flag import FeatureFlag
from support.db_utils import db_utils
Enter fullscreen mode Exit fullscreen mode

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

@asynccontextmanager
async def lifespan(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() for flag in enabled_features])
    except Exception as e:
        print(f"failed to retrieve enabled feature flags with error: {e}. Continuing application startup...")
        setattr(application.state, 'CACHED_FLAGS', [])
    yield

app = Starlette(debug=True, middleware=middleware, routes=routes, lifespan=lifespan)
Enter fullscreen mode Exit 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

import functools
import inspect
import typing
from starlette.requests import Request
from starlette.responses import JSONResponse

from models.feature_flag import FeatureFlag
from support.db_utils import db_utils

def require_feature_flag(required_flag: str):

    def feature_exec_wrapper(func: typing.Callable) -> typing.Callable:
        sig = inspect.signature(func)
        for idx, parameter in enumerate(sig.parameters.values()):
            if parameter.name == "request":
                break
        else:
            raise Exception(
                f'No "request" argument on function "{func}"'
            )

        @functools.wraps(func)
        async def exec_feature_check(*args, **kwargs):
            request = kwargs.get("request", args[idx] if idx < len(args) else None)  # <- retrieve the request from kwargs
            assert isinstance(request, Request)  # <- assert request is an instance of Request

            # retrieve feature flags from the application state
            cached_flags = getattr(request.app.state, 'CACHED_FLAGS', [])

            feature_flag = None

            # check the list of cached_flags
            if len(cached_flags) > 0:
                # search the cache
                for flag in cached_flags:
                    if flag['flag'] == required_flag:
                        print("cache hit!")
                        feature_flag = flag
            else:
                print("cache miss")
                # try to retrieve feature flags matching the required_flag
                try:
                    feature_flag = db_utils.session.query(
                        FeatureFlag
                    ).where(
                        FeatureFlag.flag == required_flag, FeatureFlag.enabled == True
                    ).scalar()
                except Exception as e:
                    print(f"failed to retrieve feature flag with error: {e}")
                    return JSONResponse({
                        'success': False,
                        'message': 'Unexpected error occurred'
                    }, status_code=500)

            if feature_flag is None:
                print(f"the feature: {required_flag} was not found or is invalid")
                return JSONResponse({
                    'success': False,
                    'message': 'Invalid feature'
                }, status_code=500)

            return await func(*args, **kwargs)

        return exec_feature_check

    return feature_exec_wrapper

Enter fullscreen mode Exit 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.

The require_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)