Skip to main content
Fabien Fouassier
Back to Writing

Feature Flag Architecture Across 14 Tenants

How I replaced a fragile xcconfig inheritance chain with a four-layer provider architecture, tooling that keeps it consistent, and documentation that made the PM stop asking which flags were on.

ArchitectureiOSFeature FlagsDXApril 26, 2026·9 min readRead article
Feature Flag Architecture Across 14 Tenants

A feature flag system has two failure modes: one where the system doesn't work, and one where nobody trusts it enough to use it. The second is harder to fix. This article covers how I replaced a fragile xcconfig inheritance chain at Richemont with a four-layer provider architecture, automated tooling, and documentation that closed the trust gap — across 14 luxury maisons.

Key takeaways

  • Flag resolution semantics must be explicit and predictable — ambiguity erodes trust faster than bugs do
  • Tooling that enforces correctness by construction is worth more than documentation that describes the rules
  • A product-facing guide is not optional: if the people operating your system don't understand it, you will answer their questions forever

The Problem Was Not Technical

When I returned to Richemont for a second engagement, I was brought back to integrate a new maison into TheView — an internal iOS client management app used across the group's luxury brands. The existing feature flag system needed to be extended to support this new tenant.

What I found was a five-level xcconfig inheritance chain. The intent was sound: a hierarchy where lower-level configs could override higher-level ones, with per-maison, per-environment overrides propagating down. In practice, the system had become unmaintainable. To understand what value a flag would actually resolve to for a specific maison in a specific environment, you had to trace through up to five files, each potentially overriding the previous one. There was no single source of truth. Flags that were enabled everywhere or nowhere still existed in every file. Nothing was documented.

The most telling symptom was behavioral: the PM regularly asked which flags were active for a given maison. Not because he was disengaged — because he had learned, correctly, that he couldn't trust the system to be in the state he expected. That kind of learned distrust is hard to rebuild with code alone.

There was also no remote control capability. Firebase was already in their toolbox. I had raised the idea of Firebase Remote Config at the end of my first mission; the team was receptive but it hadn't been acted on. Returning for the second mission, my intention was to do it properly — but first the foundation needed to be stable enough to build on.

Designing the Resolution Chain

Before writing any code, I needed to settle one question: given a flag and a maison and an environment, what is the value, and where does it come from?

The answer I designed is a four-layer priority chain, where the first provider with an opinion wins:

1. Override (debug menu)      — highest priority, dev builds only
2. Firebase Remote Config     — remote, no app release required
3. Bundle defaults            — per-maison, per-environment JSON
4. Hardcoded fallback         — false, always

The chain is strict about what "no opinion" means. If a provider has not been given a value for a flag, it returns nil — not false. Returning false would short-circuit the chain prematurely. Returning nil passes resolution down to the next layer. This distinction is what makes the system composable: each layer only needs to express deviation from the next one, not a complete world-state.

The bundle defaults layer is the backbone. Each maison has a FeatureFlags.defaults.json that defines base values and environment-specific overrides in a single file:

{
  "ENABLE_MEDIA_LIBRARY": false,
  "ENABLE_DEBUG_VIEW": false,
  "overrides": {
    "sit": {
      "ENABLE_DEBUG_VIEW": true
    },
    "uat": {
      "ENABLE_DEBUG_VIEW": true,
      "ENABLE_MEDIA_LIBRARY": true
    }
  }
}

Base values are defaults across all environments. Overrides are additive — only the flags that change per environment need to appear. This structure replaced the entire inheritance chain with a format that is readable, diffable, and editable without tracing file hierarchies.

Firebase Remote Config sits above the bundle layer. It evaluates after the debug override and before the defaults. If Firebase has no opinion on a flag — because it hasn't been configured there — the value is nil, and resolution continues to the bundle defaults. Firebase only wins when explicitly configured. This makes the remote control additive rather than disruptive: you can enable Firebase targeting for specific maisons without affecting others.

Tooling That Enforces Correctness

The architecture is only as useful as the tooling around it. A clean design maintained manually degrades quickly, especially across 14 maisons and 58 flags.

I wrote three Ruby scripts to keep the system consistent.

create_flag.rb — interactive flag creation. Adding a new feature flag requires touching six places: the Swift enum, the catalog, the JSON template, all 14 maison JSON files, and the test assertions. Getting one wrong means the system is out of sync. The script walks through the process interactively — it prompts for the case name, derives the ENABLE_* raw value, asks for debug-menu editability and a catalog description, then asks which maisons should default to true. It applies all six changes atomically and runs validation and matrix generation as post-processing steps.

=== New Feature Flag Creator ===

Step 1: Flag Identity
  Enter the Swift enum case name (camelCase): mediaLibrary
  Raw value key (ENABLE_MEDIA_LIBRARY):

Step 2: Debug Menu
  Should this flag be editable in the debug menu? (Y/n): y

Step 3: Documentation
  Brief description for the catalog: Access to shared media and product imagery

Step 4: Default Value & Maison Configuration
  Available maisons: AAL, ALS, BEM, BUC, CHL, DUN, IWC, JLC, MTB, PAN, PIA, RDU, VAC, White Label
  Which maisons should have value=true? (comma-separated, or 'all'): CHL, JLC, MTB, PAN, PIA, RDU, VAC

  Value true  for: CHL, JLC, MTB, PAN, PIA, RDU, VAC
  Value false for: AAL, ALS, BEM, BUC, DUN, IWC, White Label

  Proceed? (Y/n):

Applying changes...
  ✓ Updated FeatureFlag.swift
  ✓ Updated catalog.md
  ✓ Updated defaults.template.json
  ✓ Updated AAL/FeatureFlags.defaults.json (false)
  ...14 maisons...
  ✓ Updated FeatureFlagTests.swift

validate_catalog.rb — sync enforcement. The catalog is the single source of truth for human-readable flag documentation. The validator parses FeatureFlag.swift for enum case raw values, then checks that every raw value has a corresponding row in the catalog's validation section. Any mismatch fails with a clear list of what's missing or orphaned. It also sorts the catalog table alphabetically in-place, so it stays readable without requiring discipline from contributors.

generate_matrix.rb — state visibility. The matrix script reads every maison's JSON defaults file and generates a full flag-by-maison-by-environment table in Markdown. This file is auto-generated and checked into the repository. Anyone — developer or PM — can look at it and immediately answer "which maisons have ENABLE_CAMPAIGNS enabled in production?" without opening Xcode or tracing configs. The matrix generation runs in CI on every PR that touches flag configuration.

Together, these three scripts make the system hard to break accidentally. You can't add a flag to the Swift enum without the catalog going out of sync. You can't change a maison's defaults without the matrix updating to reflect it. The correctness properties are mechanical, not aspirational.

Firebase Remote Config: Closing the Remote Control Gap

With the bundle layer stable and the tooling in place, I integrated Firebase Remote Config as the second layer in the chain.

Firebase was already in the project's toolbox, used for crash reporting and push notifications. Adding Remote Config required configuring all 58 flags in the Firebase console, creating per-maison conditions (one condition per maison, matching the scheme names), and wiring the provider into the resolution chain.

The integration is deliberately conservative: Firebase only overrides when a value is explicitly set for a flag. If a flag shows "in-app default" in the Firebase console, the app ignores Firebase for that flag and falls through to the bundle defaults. This means the Remote Config layer has zero effect on a system that hasn't been configured — there's no risk of an unintentional override from an unconfigured flag.

The main operational consideration is caching. Firebase Remote Config caches fetched values; the app overrides the default 12-hour window with a 1-hour minimum fetch interval. Changes published in Firebase are picked up on the next app launch after the cache expires. This is important context for anyone using Remote Config to respond to a production issue: the change will propagate, but not instantly.

The Product Guide

The architecture and tooling address the technical trust gap. The product trust gap is different.

The PM's recurring question — "is this flag on for this maison?" — wouldn't be answered by giving him access to the Firebase console without explanation. I wrote a product-facing guide that describes the system at the right level of abstraction for non-technical stakeholders: what a feature flag is, how the priority chain works in plain language, what Firebase Remote Config does and doesn't do, how to read the console, and how to test a change in UAT before pushing to production.

The guide deliberately avoids implementation details. Its audience is PMs, POs, and account managers — people who need to understand what the system can do and how to operate it, not how it's built. Separating these two audiences means each document can be precise for its reader instead of hedged for everyone.

The Outcome

The work took roughly three days. By the end, Richemont had a feature flag system with explicit resolution semantics, a single-file format per maison that replaced a five-level inheritance chain, Firebase Remote Config integration for remote overrides without app releases, tooling that keeps the system internally consistent automatically, and documentation for both technical and non-technical stakeholders.

The PM stopped asking which flags were on for which maison. He started using the matrix.

That shift — from repeated questions to a trusted reference — is the signal that the system actually works. It's also the signal that the documentation earned its place.

The delivery pace on this work, combined with the maison-creation script I had written earlier in the same engagement, established a pattern with Richemont: I could be trusted with complex systems work at speed. That trust is what led to ongoing solo ownership of their eCard web app alongside the continued iOS development.

Tech at a Glance

  • iOS feature flag resolution: four-layer provider chain (override → Firebase → bundle → false)
  • Bundle defaults: per-maison JSON with environment-specific overrides
  • Firebase Remote Config: per-maison conditions, nil-return on unconfigured flags
  • Ruby tooling: create_flag.rb (interactive creation), validate_catalog.rb (sync enforcement), generate_matrix.rb (CI-generated visibility matrix)
  • Documentation: technical catalog + product-facing operational guide