Introduces a reusable feature flag framework so future features can adopt flags without writing infrastructure code. Backend: server/pkg/featureflag (Go) - Service / Provider / Decision separation per Martin Fowler's Toggle Point / Toggle Router / Toggle Configuration pattern. - Providers: StaticProvider (rules in source control), EnvProvider (FF_<KEY> overrides for ops kill switches), ChainProvider (first-hit-wins composition). - EvalContext carried through context.Context with WithEvalContext / EvalContextFrom; supports user_id, workspace_id, free-form attributes. - PercentRollout via deterministic FNV-1a bucketing; same user always lands in the same bucket so experiments do not flap between requests. - Nil-safe Service: a nil *Service or missing flag returns the caller's default so business code never panics on a missing flag. - 100% unit-test coverage with -race; go vet clean. Frontend: packages/core/feature-flags (TypeScript) - Same vocabulary as the Go side (Decision, EvalContext, Rule, PercentRollout). FNV-1a parity ensures cross-tier bucket agreement. - FeatureFlagService + StaticProvider + ChainProvider in pure TS. - React glue: FeatureFlagsProvider, useFlag(key, default), useVariant(key, default). Hooks fall back to the default when no provider is mounted so Storybook / unit tests stay simple. - Vitest tests for service, providers, hash, and React hooks. Docs: docs/feature-flags.md — wiring, EvalContext, toggle points, backend-protection note, and the standard best-practice checklist. The framework intentionally has no third-party Go deps and no API surface beyond what real callers will need. New providers (DB, remote config, LaunchDarkly) plug in by implementing Provider; no existing caller has to change. Co-authored-by: multica-agent <github@multica.ai>
7.1 KiB
Feature Flags
Multica ships a framework-level feature flag implementation:
- Backend:
server/pkg/featureflag— Go package. - Frontend:
@multica/core/feature-flags— TypeScript module with React hooks.
Both sides share the same vocabulary (Decision, EvalContext, Rule, PercentRollout) and the same FNV-1a percent bucketing, so a flag evaluated on the server and on the client lands in the same bucket for the same user.
The package is designed so new features can adopt feature flags without writing any infrastructure code — drop a rule into the static config, call Service.IsEnabled / useFlag, done.
Core concepts
[Toggle Point] --query--> [Service / Router] --read--> [Provider / Configuration]
business code static / env / chain
- A Toggle Point is the single
ifin business code. It always calls the Service, never the provider directly. - The Service (
Servicein Go,FeatureFlagServicein TS) is the router. Business code never depends on which provider is behind it. - A Provider is the configuration backend. Today we ship
StaticProvider(in-memory rules),EnvProvider(Go only — env-var override), andChainProvider(composition). A future DB or LaunchDarkly provider plugs in without changing any caller. - A Decision is the structured result:
{ enabled, variant, reason, source }.IsEnabledis the boolean projection,Variantis the raw string. UseDecisionfor diagnostic endpoints.
Four flag categories (Martin Fowler):
| Category | Lifetime | Owner | Example |
|---|---|---|---|
| Release | Days–weeks | Engineering | Hide a half-finished page behind flags_release_v2 |
| Experiment | Hours–weeks | Product / Data | A/B test checkout_algo between control and experiment-v2 |
| Ops | Short or evergreen | SRE | Kill switch billing_disable_invoice_pdf |
| Permission | Years | Product | plan_gate_enterprise_dashboard |
Manage them in the same provider but treat them differently: Release flags get deleted; Ops flags need fast override paths (FF_<KEY> env var); Permission flags use Allow lists; Experiment flags use PercentRollout.
Backend (Go)
Wiring at startup
import "github.com/multica-ai/multica/server/pkg/featureflag"
static := featureflag.NewStaticProvider()
static.LoadRules(map[string]featureflag.Rule{
"billing_new_invoice_email": {Default: true},
"checkout_algo": {
Default: false,
Variant: "experiment-v2",
Percent: &featureflag.PercentRollout{Percent: 25, By: "user_id"},
},
"ops_disable_recommendations": {Default: false},
})
// Env overrides win over static config so SREs can flip kill switches
// without redeploying: `FF_OPS_DISABLE_RECOMMENDATIONS=true ./multica-server`.
env := featureflag.NewEnvProvider("FF_")
flags := featureflag.NewService(
featureflag.NewChainProvider(env, static),
featureflag.WithLogger(logger),
)
Attaching evaluation context to the request
func middleware(flags *featureflag.Service, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ec := featureflag.EvalContext{
UserID: currentUserID(r),
WorkspaceID: currentWorkspaceID(r),
Attributes: map[string]string{"plan": currentPlan(r)},
}
ctx := featureflag.WithEvalContext(r.Context(), ec)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Toggle point in business code
if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
return s.sendNewInvoiceEmail(ctx, invoice)
}
return s.sendLegacyInvoiceEmail(ctx, invoice)
For multi-arm flags:
switch flags.Variant(ctx, "checkout_algo", "control") {
case "experiment-v2":
return checkoutV2(ctx, order)
case "experiment-v3":
return checkoutV3(ctx, order)
default:
return checkoutControl(ctx, order)
}
The Service is nil-safe and missing-key-safe: (*Service)(nil).IsEnabled(ctx, "any", true) returns true. Business code never needs to guard against a missing flag.
Frontend (TypeScript / React)
Mounting once at the root
// apps/web/app/_providers.tsx (or the equivalent root)
import {
FeatureFlagsProvider,
FeatureFlagService,
StaticProvider,
} from "@multica/core/feature-flags";
const service = new FeatureFlagService(
new StaticProvider({
billing_v2_dashboard: { default: false, allow: ["user-internal"] },
checkout_algo: { default: true, variant: "experiment-v2",
percent: { percent: 25 } },
}),
);
export function Providers({ children }: { children: ReactNode }) {
const userId = useCurrentUserId();
return (
<FeatureFlagsProvider service={service} context={{ userId }}>
{children}
</FeatureFlagsProvider>
);
}
When the backend pushes a fresh rule set (via an API response or WebSocket), call service.setProvider(new StaticProvider(remoteRules)) and the whole tree re-evaluates.
Toggle point in a component
import { useFlag, useVariant } from "@multica/core/feature-flags";
function BillingPage() {
const showV2 = useFlag("billing_v2_dashboard", false);
return showV2 ? <BillingV2 /> : <BillingV1 />;
}
function Checkout() {
const variant = useVariant("checkout_algo", "control");
switch (variant) {
case "experiment-v2": return <CheckoutV2 />;
case "experiment-v3": return <CheckoutV3 />;
default: return <CheckoutControl />;
}
}
Outside a FeatureFlagsProvider (Storybook, unit tests, error pages) useFlag / useVariant return the supplied default. You never have to mount the provider just to render a component in isolation.
Security note: never rely on the frontend alone
A frontend feature flag controls what the user sees. It does NOT enforce access. Any API route exposing the same capability MUST evaluate the matching backend flag independently. The two flags can share a key but they live in two Service instances and the backend value is the source of truth.
Best-practice checklist
Adopted from Martin Fowler, ConfigCat and Octopus.
- Naming:
{team}_{area}_{behavior}, e.g.billing_checkout_new_payment_flow. Noenable_/disable_prefixes (redundant). - One flag, one purpose: never repurpose an old flag for a new feature. Add a new flag and delete the old one.
- Plan the death of the flag at birth: open a follow-up issue to remove the flag when the rollout completes. Release flags should live days, not quarters.
- Convention:
Offis the legacy / safe state,Onis the new behavior. Lets CI test "all-off (today)" and "all-on (tomorrow)". - Kill switch fast path: ops-critical flags should be exposed via
EnvProviderso SREs can flip them without a deploy. - Backend protection: anything controlling access goes through the backend Service; the frontend flag is presentation only.
- No secrets in flags: variant values are not Secrets Manager / KMS. Use those for tokens, keys, and passwords.
See docs/design.md and docs/timezone-architecture-rfc.md for prior examples of how this pattern is used across the codebase.