How Penpot Implements Feature Flags Using the flags.cljc Module
Penpot implements feature flags as Clojure sets managed by the dynamic var *current* in common/src/app/common/flags.cljc, using a parse DSL that converts CLI-style enable-foo and disable-bar arguments into concrete flag sets that propagate through thread-local bindings during request processing.
The Penpot design platform uses a centralized Clojure module to manage feature flags across its backend and frontend. In the penpot/penpot repository, the flags.cljc file provides the foundational machinery for defining, parsing, and scoping feature flags throughout the application lifecycle.
Architecture of the Feature Flag System
Penpot organizes feature flags as Clojure sets that group related functionality into three categories: login-related flags, email-related flags, and a miscellaneous "varia" collection. The system revolves around a dynamic var named *current* that holds the active flag set for the current execution context, allowing specific flags to be bound to individual requests or operations without affecting global state.
Core Components in flags.cljc
The common/src/app/common/flags.cljc file defines the static data structures and parsing logic that power Penpot's feature flag system.
The current Dynamic Var
At line 13, the module declares a dynamic var *current* that stores the active flag set:
(def ^:dynamic *current* #{})
This var enables thread-local binding of feature flags, ensuring that downstream code can check *current* to determine which features are active for the specific request or operation in progress. When processing file updates or team operations, Penpot binds this var to a specific flag set derived from team configuration and request parameters.
Flag Groups and all-flags
The module organizes available flags into functional groups (lines 15-74):
login– Flags controlling authentication methods (e.g.,:login-with-google,:login-with-github)email– Email-related capabilities (e.g.,:email-verification,:registration)varia– Miscellaneous feature toggles
Line 74 defines all-flags as the union of these three sets, providing the master list of every possible flag:
(def all-flags (set/union login email varia))
Additionally, lines 78-100 define a default list containing flags that are enabled when the system starts, which the features subsystem uses to seed the initial feature set.
The parse DSL Function
The parse function (lines 202-221) implements a tiny domain-specific language for manipulating flag sets from string arguments:
(defn parse
[& flags]
(loop [flags (apply concat flags)
result #{}]
(let [item (first flags)]
(if (nil? item)
result
(let [sname (name item)]
(cond
(str/starts-with? sname "enable-")
(recur (rest flags)
(conj result (keyword (subs sname 7))))
(str/starts-with? sname "disable-")
(recur (rest flags)
(disj result (keyword (subs sname 8))))
:else
(recur (rest flags) result)))))))
The function processes each input by converting it to a string name. If the name begins with "enable-", the suffix (after the 7th character) becomes a keyword added to the result set. If it begins with "disable-", the suffix keyword is removed from the set. Any other input is ignored, allowing raw flag collections to pass through unchanged.
Runtime Propagation and Context Binding
Feature flags propagate through the Penpot backend via dynamic binding of *current*. When processing a file update, the system binds *current* to the flag set that should apply for that specific operation.
In backend/src/app/rpc/commands/files_update.clj (lines 27-30), the code establishes this binding:
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(update-file-data! cfg file …))
Here, features derives from the team's enabled features plus any per-request overrides. The binding scopes the flag context to the file-update pipeline, ensuring all downstream functions see consistent feature availability. Similar patterns appear in files_create.clj and files_snapshot.clj for file creation and snapshot operations respectively.
Integration with the Feature Subsystem
The flags.cljc module works in concert with common/src/app/common/features.cljc to bridge low-level flags with Penpot's feature model. The flag->feature function (lines 21-36) translates internal flag keywords (like :styles/v2) into concrete feature identifiers used for migrations and validation.
The get-enabled-features function (lines 65-68) consumes the flag set produced by parse to determine which capabilities are active. These functions power compatibility checks throughout the backend, such as check-client-features! and check-file-features!, ensuring that requests only access features enabled for their respective teams or files.
Practical Usage Examples
Parsing CLI-Style Arguments
Convert string-based configuration directives into executable flag sets:
(let [raw-flags [:enable-registration :disable-login-with-github :enable-email-verification]]
(app.common.flags/parse raw-flags))
;; => #{:registration :email-verification}
Temporarily Overriding Flags
Override the active flag set for a single operation using thread-local binding:
(let [team-features (cfeat/get-team-enabled-features cf/flags team)
request-flags (app.common.flags/parse [:enable-login-with-google])]
(binding [cfeat/*current* (set/union team-features request-flags)]
(process-some-action! cfg ...)))
Checking Feature Availability
Query the current context to conditionally execute code:
(when (contains? cfeat/*current* :login-with-google)
(do-something-special))
Summary
- Centralized definition:
flags.cljcstores all available flags as sets inlogin,email, andvariagroups, withall-flagsproviding the complete registry. - Dynamic scoping: The
*current*dynamic var enables thread-local flag binding, allowing per-request feature contexts without global state mutation. - Parsing DSL: The
parsefunction convertsenable-anddisable-prefixed strings into set operations, providing a human-readable configuration interface. - Integration layer:
features.cljctranslates flag keywords into feature identifiers for migrations, validation, and compatibility checks. - Real-world usage: File operations in
files_update.clj,files_create.clj, andfiles_snapshot.cljdemonstrate binding*current*to scope flags to specific request pipelines.
Frequently Asked Questions
What is the purpose of the current dynamic var in Penpot?
The *current* dynamic var stores the active set of feature keywords for the current execution thread. It allows Penpot to temporarily bind specific flags to individual requests—such as file updates or team operations—ensuring that downstream code sees the correct feature context without modifying global application state. According to the source code in flags.cljc (line 13), this var defaults to an empty set and gets rebound in request handlers like those found in files_update.clj.
How does the parse function handle enable and disable prefixes?
The parse function in flags.cljc (lines 202-221) treats strings or keywords as instructions based on their prefix. If an argument starts with "enable-", the function extracts the substring after the seventh character, converts it to a keyword, and adds it to the result set. If it starts with "disable-", it extracts the substring after the eighth character and removes that keyword from the set. This design allows administrators to configure flags using intuitive CLI-style arguments like :enable-registration or :disable-login-with-github.
Where are feature flags bound to a specific request context?
Feature flags are bound to request contexts in RPC command handlers, particularly in files-related operations. In backend/src/app/rpc/commands/files_update.clj (lines 27-30), the code uses binding to set cfeat/*current* to a flag set derived from team configuration before calling update-file-data!. Similar bindings appear in files_create.clj and files_snapshot.clj, ensuring that file mutations respect the feature flags enabled for that specific team or request.
How do flags.cljc and features.cljc work together?
While flags.cljc defines the raw flag keywords and the *current* dynamic var, features.cljc provides the translation layer between these internal flags and Penpot's feature model. The flag->feature function maps flag keywords to feature identifiers used for data migrations and compatibility checks, while get-enabled-features consumes the flag set to determine active capabilities. This separation allows flags.cljc to handle configuration and parsing while features.cljc manages domain-specific feature logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →