SOASAP Kotlin SDK
Local-first feature flag evaluation for Kotlin on the JVM and Android — Ktor, Spring Boot, workers, and CLI tools.
Overview
The SOASAP Kotlin SDK synchronizes feature flag configuration from SOASAP Cloud in the background and evaluates flags from an in-memory snapshot. Flag reads are synchronous, O(1), and never perform network or disk I/O on the hot path.
The SDK provides:
- Local evaluation — map lookups against an immutable snapshot via
AtomicReference - Real-time synchronization — Server-Sent Events (SSE) delta updates on a coroutine worker
- Persistent cache — disk-backed snapshot for cold starts
- Offline operation — last known snapshot when the network or control plane is unavailable
- Explicit defaults — every getter accepts a code-defined fallback
- Non-blocking startup — optional
preloadFlags = truestarts sync without blocking construction
Runtime characteristics
- O(1) local evaluation — reads from an in-memory snapshot; no network requests during evaluation
- Real-time SSE synchronization — background coroutine worker applies validated payloads with atomic snapshot swaps
- Persistent cache — disk-backed snapshot for cold starts and restarts
- Offline operation — last known snapshot when the network or control plane is unavailable
- Explicit defaults — missing, mistyped, or unsynced keys return your code-defined fallback
- Non-blocking startup —
preloadFlags = truestarts synchronization without blocking client construction
Requirements
Supported target runtimes:
- JDK 17+
- Android (OkHttp + coroutines on the JVM/Android runtime)
Supported application types:
- Ktor HTTP servers and route handlers
- Spring Boot and other JVM frameworks (manual singleton registration)
- Android applications (
Application-scoped client) - Background workers and coroutine-based job processors
- CLI tools and standalone JVM programs
Runs on Linux, Windows, macOS, and Android. Requires outbound HTTPS to api.soasap.com for SSE synchronization.
For getJson, apply the Kotlin serialization plugin in your consumer project.
Installation
Gradle (Kotlin DSL):
dependencies {
implementation("com.soasap:soasap:1.0.3")
}
Maven:
<dependency>
<groupId>com.soasap</groupId>
<artifactId>soasap</artifactId>
<version>1.0.3</version>
</dependency>
For JSON flags, enable serialization in build.gradle.kts:
plugins {
kotlin("plugin.serialization") version "2.0.0"
}
Verify installation
./gradlew dependencies --configuration runtimeClasspath | grep com.soasap:soasap
Confirm com.soasap:soasap appears in the dependency tree before continuing.
Before you begin
You will need:
- ✓ A SOASAP account
- ✓ A project
- ✓ An environment
- ✓ An environment API key
If you have not completed these steps, start with Quick Start.
Installation flow
After your project and environment exist in the dashboard, add the Maven dependency and construct
one SoasapClient at application startup. The API key binds the process to a single
environment. Synchronization runs in a background coroutine; application code evaluates flags locally.
Client initialization
Create one thread-safe client per process and enable preload for production servers and Android apps:
import soasap.sdk.SoasapClient
import soasap.sdk.SoasapOptions
val apiKey = System.getenv("SOASAP_API_KEY")
?: error("SOASAP_API_KEY is required.")
val options = SoasapOptions(apiKey).apply {
preloadFlags = true
}
val client = SoasapClient(options)
Fluent builder (optional):
import soasap.sdk.soasap
val client = soasap(apiKey)
.preloadFlags()
.onError { ctx ->
println("[SOASAP ${ctx.source}] ${ctx.exception.message}")
}
.build()
- API key — environment-scoped secret sent as
X-API-Keyon SSE requests; identifies which configuration to synchronize - Singleton client — one
SoasapClientper process; share across threads, routes, and coroutines - Preload — with
preloadFlags = true, the SSE coroutine worker starts during construction without blocking the constructor - Shutdown — always call
close()or use Kotlin.use { }to flush disk cache and stop the worker
Configuration
Read the API key from environment variables, system properties, or your framework's configuration layer. The SDK does not load config files automatically.
Recommended configuration
Development
- Environment variables or local
application.conf - Android:
BuildConfigor debug-only local properties
Production
- Environment variables
- Kubernetes Secrets
- AWS Secrets Manager / Azure Key Vault (injected at deploy time)
- Android: secure storage or build-time injection via CI
Never commit Production API keys to source control. Surface secrets as environment variables at deploy time.
Environment variable:
SOASAP_API_KEY=soasap...
val options = SoasapOptions(System.getenv("SOASAP_API_KEY")!!).apply {
preloadFlags = true
}
Optional configuration:
val options = SoasapOptions(apiKey).apply {
baseUrl = "https://api.soasap.com"
cacheDirectory = "/var/lib/myapp/soasap-cache"
preloadFlags = true
onError = { ctx ->
println("[${ctx.source}] transient=${ctx.isTransient} ${ctx.exception.message}")
}
}
Builder equivalents: withCacheDirectory(...), onError { }, preloadFlags() (alias: useBackgroundWorker()).
Evaluating flags
All getters read from the current in-memory snapshot. Wrong JSON types return the default you provide.
| Flag type | Method |
|---|---|
| Boolean | getBool |
| String | getString |
| Number | getNumber |
| JSON | getJson |
Boolean
val enabled = client.getBool("new-checkout", defaultValue = false)
getFlag is an alias for getBool.
String
val theme = client.getString("checkout-theme", defaultValue = "default")
Number
SOASAP number flags map to Kotlin Double. Use for integers, limits, rates, and thresholds.
val maxItems = client.getNumber("max-items", defaultValue = 10.0)
val discountRate = client.getNumber("discount-rate", defaultValue = 0.0)
JSON
@Serializable data classes whenever possible instead of untyped maps. Strong typing improves discoverability, validation, refactoring safety, and maintainability.
import kotlinx.serialization.Serializable
@Serializable
data class CheckoutConfig(
val enableUpsells: Boolean = false,
val maxItems: Int = 0,
)
val config = client.getJson<CheckoutConfig>("checkout-config")
With an explicit serializer:
val config = client.getJson(
"checkout-config",
CheckoutConfig.serializer(),
defaultValue = CheckoutConfig(),
)
Use @SerialName when JSON keys from the dashboard differ from Kotlin property names.
Unknown keys are ignored (ignoreUnknownKeys = true).
Deserialization failures return the default; getters do not throw.
Explicit defaults
Always pass a default on every read:
client.getBool("new-checkout", defaultValue = false)
The default is returned when:
- The flag key does not exist in the synchronized snapshot
- The flag was deleted or disabled in the dashboard for this environment
- The process starts before the first SSE payload arrives and no disk cache exists
- A restored disk cache is empty or invalid
- The flag value type does not match the getter (for example, a string flag read with
getBool) - Synchronization is temporarily delayed after deploy or reconnect
Explicit defaults are your production safety net. They define safe behavior when configuration is missing, stale, or not yet synchronized — without try/catch on the hot path.
false for feature gates and conservative values for limits.
Runtime architecture
- SSE delivers full or delta flag payloads to a coroutine worker on
Dispatchers.IO - Valid payloads replace the in-memory snapshot atomically via
AtomicReference.set— readers never observe partial updates - Evaluation reads the current snapshot reference with no locks on the request path
- No HTTP requests and no blocking disk I/O during
getBool,getNumber,getString, orgetJson
Implementation details on the hot path:
- Snapshot replacement swaps an immutable
FlagSnapshotreference — concurrent readers keep the previous snapshot until the swap completes SoasapClientis thread-safe; safe to call from any thread or coroutine- SSE parser enforces a 5 MB payload cap; invalid root payloads are ignored
Persistent cache
On construction, the client synchronously loads the last persisted JSON snapshot from disk before any network activity. SSE updates enqueue debounced disk writes (coalesced every ~2.5 seconds) via a bounded channel.
Cache file name: soasap_cache_{hash}.json (first 8 hex characters of SHA-256 over the API key).
Default cache directories:
| Platform | Path |
|---|---|
| Windows | %LOCALAPPDATA%\soasap\cache\soasap_cache_{hash}.json |
| Linux / macOS | $XDG_DATA_HOME/soasap/cache or ~/.local/share/soasap/cache |
| Fallback | {cwd}/soasap/cache/soasap_cache_{hash}.json |
Override with cacheDirectory = "/custom/path" or .withCacheDirectory("/custom/path").
Deploy and restart — rolling deploys and app restarts hydrate from disk immediately; SSE reconnects in the background. First deploy on a new machine without cache returns defaults until the first successful sync.
See Persistent Cache for write coalescing and crash-safe atomic file replace details.
Offline operation
The SDK continues evaluating flags from the last known snapshot when:
- Internet connectivity is unavailable
- SOASAP Cloud returns errors or is unreachable
- The SSE connection drops (proxy timeout, firewall, transient network failure)
The background worker reconnects with exponential backoff (1s → 2s → 5s → 10s → 30s, ±200ms jitter). Reads are unaffected during disconnect — only future updates are delayed.
Stale configuration is preferable to unavailable configuration: the application keeps running with the last known values and your code defaults.
| Scenario | Behavior |
|---|---|
| API unavailable | Serves stale cached snapshot |
| SSE disconnected | Keeps last known snapshot; reconnects with backoff |
| First startup, no cache | Returns explicit defaults until first sync |
| Invalid SSE payload | Payload ignored; snapshot unchanged |
| Disk cache failure | In-memory mode continues; error reported via onError |
Preload and startup behavior
preloadFlags = true is recommended for production JVM servers, Android apps, and long-running workers.
With preload:
- Client construction loads the disk cache synchronously
- The SSE coroutine worker starts immediately — the constructor returns without awaiting network I/O
- The host keeps booting while synchronization completes in the background
- First requests may read disk cache or explicit defaults until the first SSE payload arrives
Without preload (lazy mode):
val options = SoasapOptions(apiKey)
No background worker runs until the first flag read. Until then, evaluation uses the disk cache (if present) or your explicit defaults while SSE connects in the background. Prefer preload for predictable synchronization timing after deploy.
Graceful shutdown
Call close() when the process exits to cancel the SSE job, flush the disk cache (up to 5 seconds), and release OkHttp resources:
SoasapClient(options).use { client ->
// use flags
} // close() called automatically
For long-running servers, register a shutdown hook or framework lifecycle callback:
Runtime.getRuntime().addShutdownHook(Thread {
client.close()
})
On Android, call close() from Application.onTerminate() or your DI teardown. See Android Integration.
Best practices
- Create once per process — single
SoasapClientat application or module scope; register as a DI singleton in Ktor, Spring, or Android. - Reuse the client — inject
SoasapClientInterfaceinto routes, services, ViewModels, and workers. - Store API keys securely — see Recommended configuration.
- Enable preload —
preloadFlags = truefor non-blocking background sync at startup. - Always pass explicit defaults — safe behavior when keys are missing or not yet synchronized.
- Call
close()on shutdown — flush cache and stop the coroutine worker cleanly. - Safe on any thread — getters are thread-safe; no need to confine reads to the main thread on Android.
- Use environment-specific API keys — Development, Staging, and Production hold independent configuration.
Common issues
Flag always returns the default
- Verify the API key matches the intended environment
- Confirm the flag key exists and is enabled in that environment
- Check the flag type matches the getter (
getBoolfor boolean flags)
Dashboard changes do not appear
- Verify outbound HTTPS to
api.soasap.comand SSE is not blocked by a proxy or firewall - See SSE Disconnected
Wrong value returned
- Verify the flag value in the selected environment — Development and Production are independent
- Confirm the deployed process uses the API key for that environment
getJson returns null or default
- Ensure the Kotlin serialization plugin is applied in your module
- Verify property names match dashboard JSON keys (use
@SerialNamewhen they differ) - Confirm the flag stores a JSON object or array, not a primitive
Multiple SDK instances
Symptoms
- Multiple SSE connections from the same process
- Increased memory usage
- Duplicate synchronization traffic
Resolution — construct SoasapClient once and reuse the same instance throughout the application. Do not create a new client per request, route, or Activity.
Startup synchronization delays
Expected with preload: the process starts immediately; the first SSE payload may arrive seconds after boot. Until then, evaluation uses the disk cache or explicit defaults. This is by design — startup is never blocked on sync.
Complete example
The following example demonstrates the recommended production setup for a Ktor JVM application, including configuration, client initialization, preload, shutdown hook, and runtime flag evaluation.
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import soasap.sdk.SoasapClient
import soasap.sdk.SoasapOptions
fun main() {
val apiKey = System.getenv("SOASAP_API_KEY")
?: error("Set SOASAP_API_KEY via environment variable.")
val options = SoasapOptions(apiKey).apply {
preloadFlags = true
onError = { ctx ->
println("[SOASAP ${ctx.source}] ${ctx.exception.message}")
}
}
val flags = SoasapClient(options)
Runtime.getRuntime().addShutdownHook(Thread {
flags.close()
})
embeddedServer(Netty, port = 8080) {
routing {
get("/checkout") {
if (!flags.getBool("new-checkout", defaultValue = false)) {
call.respond(mapOf("mode" to "legacy"))
return@get
}
call.respond(
mapOf(
"mode" to "new",
"maxItems" to flags.getNumber("max-items", 10.0).toInt(),
"theme" to flags.getString("checkout-theme", "default"),
),
)
}
get("/health") {
if (flags.getBool("maintenance-mode", defaultValue = false)) {
call.respondText("Maintenance")
return@get
}
call.respondText("OK")
}
}
}.start(wait = true)
}
Production recommendations
- Store API keys in a secret manager; inject via environment variables at runtime
- Use separate API keys per environment — never share Production keys with Development builds
- Never commit Production keys to source control or embed them in container images unnecessarily
- Monitor outbound connectivity and SSE health; alert on prolonged disconnect in Production
- Keep explicit defaults in code for every flag read — defaults define behavior during outages
- Register
onErrorin Production to log background sync, disk, and parser failures without affecting reads - Register shutdown hooks (JVM) or lifecycle callbacks (Android) to call
close()
Next steps
- Feature Flags — flag types, defaults, and naming
- Architecture — local evaluation, SSE, cache, and offline operation
- API Keys — issuing, rotating, and securing environment keys
- Other SDKs — .NET, Node.js, Python, and more
Deep dives: Initialization, Coroutines, Offline Behavior.