Skip to content

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.

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];

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.ApplicationContext
import 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.LogEnabled
import cards.arda.common.lib.util.log.LogProvider
import 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
}
}

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
}

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 time
val 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 context
class 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
}
// ...
}
}

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 JWTContext
const 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]);
src/hooks/useFeatureFlag.ts
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 component
const 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.

src/app/api/features/route.ts
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 flicker
posthog.init(KEY, { bootstrap: { featureFlags: serverFlags } });
  • Exact com.posthog.java, posthog-node, and posthog-js/react method 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.