Core DSL best practices
This page describes the recommended way to compose Konditional features using what the current public API actually exposes. Use these patterns as your baseline for production definitions and reviews.
Build from stable namespace boundaries
Start by grouping related flags into one Namespace. Keep each namespace
focused so you can load, rollback, and disable behavior without cross-domain
side effects.
import io.amichne.konditional.context.Context
import io.amichne.konditional.core.Namespace
object CheckoutFlags : Namespace("checkout") {
val fastPath by boolean<Context>(default = false)
val timeoutMs by integer<Context>(default = 1500)
}
Prefer explicit, typed rule composition
Use the value-first rule form for straightforward rules. Use the
criteria-first rule { ... } yields value form when the targeting block is
long and the produced value is easier to read at the end.
import io.amichne.konditional.context.Context
import io.amichne.konditional.context.Platform
object AppFlags : Namespace("app") {
val checkoutVariant by string<Context>(default = "classic") {
rule("fast") { ios() }
rule {
platforms(Platform.ANDROID)
rampUp { 25.0 }
note("Android fast-path canary")
} yields "fast"
}
}
Use whenContext<R : Context> for capability narrowing
When a feature is defined on a broad context type, add gated logic with
whenContext so rules remain total and deterministic for all contexts.
import io.amichne.konditional.context.Context
import io.amichne.konditional.core.dsl.rules.targeting.scopes.whenContext
enum class Tier { FREE, ENTERPRISE }
data class EnterpriseContext(
override val locale: AppLocale,
override val platform: Platform,
override val appVersion: Version,
override val stableId: StableId,
val tier: Tier,
) : Context
object EntitlementFlags : Namespace("entitlements") {
val premiumUi by boolean<Context>(default = false) {
rule(true) {
whenContext<EnterpriseContext> { tier == Tier.ENTERPRISE }
}
}
}
If the runtime context is not EnterpriseContext, the whenContext predicate
returns false for that leaf instead of throwing.
Treat axes as first-class dimensions
Prefer explicit axis handles for readability and locality. Use inferred axis
values only when the namespace already owns the axis registration for that
value type. Axis IDs are derived from enum fully-qualified class names by
default, and you can pin a custom ID with @KonditionalExplicitId.
import io.amichne.konditional.context.axis.KonditionalExplicitId
@KonditionalExplicitId("environment")
enum class Environment(override val id: String) : AxisValue<Environment> {
PROD("prod"),
STAGE("stage"),
}
object RolloutFlags : Namespace("rollouts") {
private val environmentAxis = axis<Environment>()
val checkout by boolean<Context>(default = false) {
rule(true) { axis(environmentAxis, Environment.PROD) }
rule(true) { axis(Environment.STAGE) }
}
}
Reuse rule logic with RuleSet and include
Use ruleSet to capture reusable targeting blocks, then include them in
multiple features. Composition order is deterministic and left-to-right.
import io.amichne.konditional.core.dsl.ruleSet
object AppFlags : Namespace("app") {
val checkout by string<Context>(default = "classic")
private val iosCanary = checkout.ruleSet {
rule("fast") {
ios()
rampUp { 10.0 }
}
}
val checkoutUi by string<Context>(default = "classic") {
include(iosCanary)
rule("classic") { android() }
}
}
Keep rollout intent explicit
Use rampUp for deterministic progressive rollout, allowlist for explicit
exceptions, and salt("...") to intentionally reshuffle cohorts.
object PaymentsFlags : Namespace("payments") {
val v2Flow by boolean<Context>(default = false) {
salt("v2")
allowlist(StableId.of("employee-123"))
rule(true) {
rampUp { 20.0 }
note("20% rollout with tester bypass")
}
}
}
Model custom values with Konstrained
Use object schemas for multi-field values and value classes for primitive or array wrappers. Keep schema definitions deterministic and construction typed.
import io.amichne.konditional.core.types.Konstrained
import io.amichne.kontracts.dsl.arraySchema
import io.amichne.kontracts.dsl.stringSchema
import io.amichne.kontracts.schema.ArraySchema
@JvmInline
value class Tags(val values: List<String>) : Konstrained<ArraySchema<String>> {
override val schema = arraySchema { elementSchema(stringSchema { minLength = 1 }) }
}
object ContentFlags : Namespace("content") {
val promotedTags by custom<Tags, Context>(default = Tags(listOf("default")))
}
Keep the runtime boundary explicit
Use NamespaceSnapshotLoader as the JSON boundary. Handle Result directly,
log typed parse errors, and rely on last-known-good behavior when load fails.
import io.amichne.konditional.core.result.parseErrorOrNull
import io.amichne.konditional.serialization.snapshot.NamespaceSnapshotLoader
val result = NamespaceSnapshotLoader(AppFlags).load(remoteJson)
result.onFailure { failure ->
val parseError = result.parseErrorOrNull()
println("Config rejected: ${parseError?.message ?: failure.message}")
}
NamespaceSnapshotLoader.load(...) decodes and loads atomically on success.
For recovery workflows, use rollback(...) from io.amichne.konditional.runtime.
Add observability without changing semantics
Attach hooks for logs and metrics, and use shadow evaluation for migration comparisons while preserving baseline outputs.
import io.amichne.konditional.api.evaluateWithShadow
import io.amichne.konditional.core.ops.KonditionalLogger
import io.amichne.konditional.core.ops.RegistryHooks
AppFlags.setHooks(
RegistryHooks.of(
logger = object : KonditionalLogger {
override fun warn(message: () -> String, throwable: Throwable?) {
println(message())
}
},
),
)
val value = AppFlags.checkout.evaluateWithShadow(
context = ctx,
candidateRegistry = candidateRegistry,
onMismatch = { mismatch ->
println("Shadow mismatch for ${mismatch.featureKey}: ${mismatch.kinds}")
},
)