Feature Flags — Code Sketch
A concrete starting point for the PostHog-first design in the Spike Analysis. It is illustrative, not copy-paste-ready: method signatures should be confirmed against the installed com.posthog.java / posthog-js / posthog-node versions, and package paths adjusted to the host repo.
The shape follows the spike’s two rules: one thin abstraction per side (never call the vendor directly from feature code), and identity comes from the verified ApplicationContext scope on the backend, never a role claim.
1. Shared flag registry
Section titled “1. Shared flag registry”One source of truth for keys, mirrored as an enum on each side. Keys are kebab-case strings; the enum is what feature code references.
Backend — common-module:
package cards.arda.common.lib.runtime.flags
/** The canonical set of feature-flag keys. Mirror in the frontend registry. */enum class FeatureFlag(val key: String) { PDEV_679_SPIKE("pdev-679-spike"), // add flags here; delete the moment a release toggle reaches GA ;
companion object { fun fromKey(key: String): FeatureFlag? = entries.firstOrNull { it.key == key } }}Frontend — src/types/feature-flags.ts:
// Mirror of the backend FeatureFlag enum. Keep the string values identical.export const FEATURE_FLAGS = { PDEV_679_SPIKE: "pdev-679-spike",} as const;
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS];2. Backend (Kotlin)
Section titled “2. Backend (Kotlin)”2.1 The abstraction
Section titled “2.1 The abstraction”FeatureFlags is the only thing feature code sees. Identity is resolved from the coroutine ApplicationContext, so callers never pass a user or tenant — they cannot get it wrong or spoof it.
package cards.arda.common.lib.runtime.flags
import cards.arda.common.lib.runtime.ApplicationContextimport cards.arda.common.lib.runtime.ServiceScope
/** Identity a flag is evaluated against, derived from the request scope. */data class FlagContext( val distinctId: String, // Cognito sub val tenantId: String?, // tenant UUID as string, null for Global scope) { companion object { fun from(scope: ServiceScope): FlagContext = FlagContext( distinctId = scope.maybeSubject ?: "anonymous", tenantId = scope.maybeTenant?.toString(), ) }}
interface FeatureFlags { /** Evaluate a boolean flag for the current request scope. Never throws — falls back to [default]. */ suspend fun isEnabled(flag: FeatureFlag, default: Boolean = false): Boolean
/** Evaluate against an explicit context (jobs, tests, M2M with no request scope). */ suspend fun isEnabled(flag: FeatureFlag, ctx: FlagContext, default: Boolean = false): Boolean}
/** Pulls the scope from the coroutine context so feature code stays argument-free. */suspend fun FeatureFlags.isEnabled(flag: FeatureFlag, default: Boolean = false): Boolean = ApplicationContext.current() .map { isEnabled(flag, FlagContext.from(it.scope), default) } .getOrDefault(default)2.2 PostHog local-evaluation implementation
Section titled “2.2 PostHog local-evaluation implementation”Wraps the PostHog Java client in local-evaluation mode: flag definitions are polled and evaluated in-process, so there is no network hop per check. Tenant is passed as the tenant_id person property (the free, no-add-on approach from spike §6.3); switch to groups if Group Analytics is adopted.
package cards.arda.common.lib.runtime.flags
import cards.arda.common.lib.util.log.LogEnabledimport cards.arda.common.lib.util.log.LogProviderimport com.posthog.java.PostHog // confirm artifact + signatures against the installed version
class PostHogFeatureFlags( private val client: PostHog,) : FeatureFlags, LogEnabled by LogProvider(PostHogFeatureFlags::class) {
override suspend fun isEnabled(flag: FeatureFlag, default: Boolean): Boolean = ApplicationContext.current() .map { isEnabled(flag, FlagContext.from(it.scope), default) } .getOrDefault(default)
override suspend fun isEnabled(flag: FeatureFlag, ctx: FlagContext, default: Boolean): Boolean = runCatching { val personProperties = buildMap { ctx.tenantId?.let { put("tenant_id", it) } } // Local evaluation requires the targeting properties to be supplied at call time. client.isFeatureEnabled( /* key = */ flag.key, /* distinctId = */ ctx.distinctId, /* defaultValue = */ default, /* groups = */ emptyMap(), /* personProps = */ personProperties, /* groupProps = */ emptyMap(), ) }.getOrElse { ex -> // Fail safe: a flag system outage must never take down a feature path. log.warn("Flag eval failed for {}, returning default {}: {}", flag.key, default, ex.message) default }}2.3 Test / fallback implementation
Section titled “2.3 Test / fallback implementation”Deterministic, no network — for unit tests and as the wiring fallback when PostHog is not configured (e.g. local dev).
package cards.arda.common.lib.runtime.flags
class StaticFeatureFlags( private val enabled: Set<FeatureFlag> = emptySet(),) : FeatureFlags { override suspend fun isEnabled(flag: FeatureFlag, default: Boolean) = flag in enabled override suspend fun isEnabled(flag: FeatureFlag, ctx: FlagContext, default: Boolean) = flag in enabled}2.4 Wiring and usage
Section titled “2.4 Wiring and usage”Build one client per process (it owns the polling thread), then inject the FeatureFlags into services. Sketch of the module-style factory, mirroring how operations builds its other dependencies:
// at component build timeval flags: FeatureFlags = config.tryGetString("posthog.apiKey")?.let { apiKey -> val client = PostHog.Builder(apiKey) .host(config.tryGetString("posthog.host") ?: "https://us.i.posthog.com") .build() // enable local evaluation per the SDK (personal/secure key + poll interval) PostHogFeatureFlags(client) } ?: StaticFeatureFlags() // dev / CI fallback// in a service — argument-free; scope comes from the coroutine contextclass ItemService(private val flags: FeatureFlags) { suspend fun listItems(/* ... */): Result<Page<Item>> { if (flags.isEnabled(FeatureFlag.PDEV_679_SPIKE)) { // new dormant path, revealed only to targeted tenants/users } // ... }}3. Frontend (Next.js / React)
Section titled “3. Frontend (Next.js / React)”PostHog (posthog-js) is already initialized in the provider tree, so the frontend can read flags directly. Two layers: a typed hook for components, and an identify step so targeting works. A BFF bootstrap route is optional but removes first-paint flicker.
3.1 Identify the user (in AuthInit, once identity is known)
Section titled “3.1 Identify the user (in AuthInit, once identity is known)”import { usePostHog } from "posthog-js/react";
// inside AuthInit, after sub / tenant are available from JWTContextconst posthog = usePostHog();useEffect(() => { if (!userContext) return; posthog?.identify(userContext.userId, { // person property used for free, no-add-on tenant targeting (spike §6.3) tenant_id: userContext.tenantId, });}, [posthog, userContext]);3.2 The hook
Section titled “3.2 The hook”import { useFeatureFlagEnabled } from "posthog-js/react";import type { FeatureFlagKey } from "@/types/feature-flags";
/** Thin typed wrapper — components never reference posthog directly. */export function useFeatureFlag(flag: FeatureFlagKey): boolean { return useFeatureFlagEnabled(flag) ?? false;}// usage in a componentconst showSpike = useFeatureFlag(FEATURE_FLAGS.PDEV_679_SPIKE);return showSpike ? <NewModule /> : null;3.3 Optional: BFF bootstrap route (no first-paint flicker)
Section titled “3.3 Optional: BFF bootstrap route (no first-paint flicker)”Evaluate flags on the server with the identity already extracted by processJWTForArda(), return them, and seed posthog-js via its bootstrap option.
import { NextRequest, NextResponse } from "next/server";import { PostHog } from "posthog-node";import { processJWTForArda } from "@/lib/jwt";
const ph = new PostHog(process.env.POSTHOG_KEY!, { host: process.env.POSTHOG_HOST, personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY, // enables local evaluation});
export async function GET(request: NextRequest) { const { userContext } = await processJWTForArda(request); const flags = await ph.getAllFlags(userContext.userId, { personProperties: { tenant_id: userContext.tenantId }, }); return NextResponse.json({ flags });}// at posthog-js init — seed from a server-rendered payload to avoid flickerposthog.init(KEY, { bootstrap: { featureFlags: serverFlags } });4. What to confirm before building
Section titled “4. What to confirm before building”- Exact
com.posthog.java,posthog-node, andposthog-js/reactmethod signatures and local-evaluation setup for the installed versions. - The local-evaluation key type (personal vs Feature Flags Secure API key) per spike §6.3.
- Whether to target by person property (
tenant_id, free) or group (needs the Group Analytics add-on). - Config keys and secret delivery (HOCON + ExternalSecrets) for the PostHog key in each environment.
Copyright: © Arda Systems 2025-2026, All rights reserved