Recipes: Best-Practice Patterns
Practical patterns for real-world feature control using only Konditional building blocks. Each recipe highlights a supported solution area and makes the guarantee boundaries explicit.
Covered solution areas:
- Typed features (booleans, enums, structured values)
- Deterministic rollouts and salting
- Axes and custom context targeting
- Remote configuration (snapshot/patch boundary + rollback)
- Shadow evaluation for safe migrations
- Namespace isolation and kill-switch
- Observability hooks (logging + metrics)
Typed Variants Instead of Boolean Explosion
When you have multiple rollout variants, model them as a typed value (enum or string) rather than composing booleans.
enum class CheckoutVariant { CLASSIC, FAST_PATH, NEW_UI }
object CheckoutFlags : Namespace("checkout") {
val variant by enum<CheckoutVariant, Context>(default = CheckoutVariant.CLASSIC) {
rule(CheckoutVariant.FAST_PATH) { rampUp { 10.0 } }
rule(CheckoutVariant.NEW_UI) { rampUp { 1.0 } }
}
}
fun renderCheckout(context: Context) {
when (CheckoutFlags.variant.evaluate(context)) {
CheckoutVariant.CLASSIC -> renderClassic()
CheckoutVariant.FAST_PATH -> renderFastPath()
CheckoutVariant.NEW_UI -> renderNewUi()
}
}
- Guarantee: Variant values are compile-time correct and exhaustively handled.
- Mechanism: Enum-typed feature delegates (
enum<...>) and Kotlinwhenexhaustiveness. - Boundary: Remote JSON can only select enum constants already compiled into the binary.
Deterministic Ramp-Up with Resettable Salt
Gradually roll out a feature without reshuffling users; use salt(...) when you need a clean resample.
object RampUpFlags : Namespace("ramp-up") {
val newCheckout by boolean<Context>(default = false) {
salt("v1")
enable { rampUp { 10.0 } }
}
}
fun isCheckoutEnabled(context: Context): Boolean =
RampUpFlags.newCheckout.evaluate(context)
To restart the experiment with a fresh sample:
object RampUpResetFlags : Namespace("ramp-up-reset") {
val newCheckout by boolean<Context>(default = false) {
salt("v2")
enable { rampUp { 10.0 } }
}
}
- Guarantee: Same
(stableId, flagKey, salt)always yields the same bucket. - Mechanism: SHA-256 deterministic bucketing in
RampUpBucketing. - Boundary: Changing
saltintentionally redistributes buckets.
Runtime-Configurable Segments via Axes
Use axes for segment targeting you want to update via JSON (without redeploying predicates).
enum class Segment(override val id: String) : AxisValue<Segment> {
CONSUMER("consumer"),
SMB("smb"),
ENTERPRISE("enterprise"),
}
object Axes {
object SegmentAxis : Axis<Segment>("segment", Segment::class)
}
object SegmentFlags : Namespace("segment") {
@Suppress("UnusedPrivateProperty")
private val segmentAxis = Axes.SegmentAxis
val premiumUi by boolean<Context>(default = false) {
enable { axis(Segment.ENTERPRISE) }
}
}
fun isPremiumUiEnabled(): Boolean {
val segmentContext =
object :
Context,
Context.LocaleContext,
Context.PlatformContext,
Context.VersionContext,
Context.StableIdContext {
override val locale = AppLocale.UNITED_STATES
override val platform = Platform.IOS
override val appVersion = Version.of(2, 1, 0)
override val stableId = StableId.of("user-123")
override val axisValues = axisValues { +Segment.ENTERPRISE }
}
return SegmentFlags.premiumUi.evaluate(segmentContext)
}
- Guarantee: Segment targeting is type-safe and serializable.
- Mechanism: Axis IDs are stored in JSON;
axis(...)evaluates againstContext.axisValues. - Boundary: Axis IDs must remain stable across builds and obfuscation.
Business Logic Targeting with Custom Context + Extension
Use strongly-typed extensions for domain logic that should not be remotely mutable.
data class EnterpriseContext(
override val locale: AppLocale,
override val platform: Platform,
override val appVersion: Version,
override val stableId: StableId,
val subscriptionTier: SubscriptionTier,
val employeeCount: Int,
) : Context, Context.LocaleContext, Context.PlatformContext, Context.VersionContext, Context.StableIdContext
enum class SubscriptionTier { FREE, PRO, ENTERPRISE }
object PremiumFeatures : Namespace("premium") {
val advancedAnalytics by boolean<EnterpriseContext>(default = false) {
enable {
extension { subscriptionTier == SubscriptionTier.ENTERPRISE && employeeCount > 100 }
}
}
}
- Guarantee: Extension predicates are type-safe and enforced at compile time.
- Mechanism:
Feature<T, EnterpriseContext>makes the extension receiver strongly typed. - Boundary: Extension logic is not serialized; only its rule parameters (e.g., ramp-up) can be updated remotely.
Structured Values with Schema Validation
Use custom<T> for structured configuration that must be validated at the JSON boundary.
data class RetryPolicy(
val maxAttempts: Int = 3,
val backoffMs: Double = 1000.0,
val enabled: Boolean = true,
) : Konstrained<ObjectSchema> {
override val schema = schemaRoot {
::maxAttempts of { minimum = 1 }
::backoffMs of { minimum = 0.0 }
::enabled of { default = true }
}
}
object PolicyFlags : Namespace("policy") {
val retryPolicy by custom<RetryPolicy, Context>(default = RetryPolicy()) {
rule(RetryPolicy(maxAttempts = 5, backoffMs = 2000.0)) { platforms(Platform.ANDROID) }
}
}
- Guarantee: Invalid structured config is rejected before it reaches evaluation.
- Mechanism: Kontracts schema validation at
ConfigurationSnapshotCodec.decode(...). - Boundary: Semantic correctness of field values (e.g., "appropriate backoff") remains a human responsibility.
Safe Remote Config Loading + Rollback
Use ParseResult to enforce a hard boundary at the JSON parse step, and roll back on bad updates.
fun loadRemoteConfig() {
val json = fetchRemoteConfig()
val features = AppFeatures
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> features.load(result.value)
is ParseResult.Failure -> RecipeLogger.error { "Config rejected: ${result.error.message}" }
}
}
If a later update causes issues:
fun rollbackConfig() {
val success = AppFeatures.rollback(steps = 1)
if (!success) RecipeLogger.warn { "Rollback failed: insufficient history" }
}
- Guarantee: Invalid config never becomes active; swaps are atomic.
- Mechanism:
ParseResultboundary +Namespace.load(...)atomic swap. - Boundary: A valid config can still be logically wrong; rollback is the safe escape hatch.
Controlled Migrations with Shadow Evaluation
Compare a candidate configuration to baseline behavior without changing production outputs.
fun evaluateWithShadowedConfig(context: Context): Boolean {
val candidateJson = fetchCandidateConfig()
val candidateConfig = ConfigurationSnapshotCodec.decode(candidateJson).getOrThrow()
val candidateRegistry =
InMemoryNamespaceRegistry(namespaceId = AppFeatures.namespaceId).apply {
load(candidateConfig)
}
val value =
AppFeatures.darkMode.evaluateWithShadow(
context = context,
candidateRegistry = candidateRegistry,
onMismatch = { mismatch ->
RecipeLogger.warn {
"shadowMismatch key=${mismatch.featureKey} kinds=${mismatch.kinds} baseline=${mismatch.baseline.value} candidate=${mismatch.candidate.value}"
}
},
)
return applyDarkMode(value)
}
- Guarantee: Production behavior stays pinned to baseline while candidate is evaluated.
- Mechanism:
evaluateWithShadow(...)evaluates baseline + candidate but returns baseline value. - Boundary: Shadow evaluation is inline and adds extra work to the hot path; sample if needed.
Namespace Isolation + Kill-Switch
Use separate namespaces for independent lifecycles, and a scoped kill-switch for emergencies.
sealed class AppDomain(id: String) : Namespace(id) {
data object Payments : AppDomain("payments") {
val applePay by boolean<Context>(default = false)
}
data object Search : AppDomain("search") {
val reranker by boolean<Context>(default = false)
}
}
fun disablePayments() {
AppDomain.Payments.disableAll()
}
- Guarantee: Disabling a namespace only affects that namespace.
- Mechanism: Each
Namespacehas an isolated registry and kill-switch. - Boundary:
disableAll()returns defaults; it does not modify feature definitions or remote config state.
Lightweight Observability Hooks
Attach logging and metrics without depending on a specific vendor SDK.
fun attachHooks() {
val hooks =
RegistryHooks.of(
logger =
object : KonditionalLogger {
override fun warn(message: () -> String, throwable: Throwable?) {
AppLogger.warn(message(), throwable)
}
},
metrics =
object : MetricsCollector {
override fun recordEvaluation(event: Metrics.Evaluation) {
AppMetrics.increment("konditional.eval", tags = mapOf("feature" to event.featureKey))
}
},
)
AppFeatures.setHooks(hooks)
}
- Guarantee: Hooks receive evaluation and lifecycle signals with consistent payloads.
- Mechanism:
RegistryHooksare invoked inside the runtime's evaluation and load paths. - Boundary: Hooks run on the hot path; keep them non-blocking.