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 = true starts 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 startuppreloadFlags = true starts synchronization without blocking client construction
Production behavior. Getters never throw. The SDK treats feature flags as secondary infrastructure and does not block or crash the host on sync failures. Runtime dependencies: OkHttp, Kotlin coroutines, and kotlinx.serialization.

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-Key on SSE requests; identifies which configuration to synchronize
  • Singleton client — one SoasapClient per 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.

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
BooleangetBool
StringgetString
NumbergetNumber
JSONgetJson

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

Best practice. Use @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.

Rule of thumb. If a flag controls production behavior, the default should be the safest possible outcome — usually 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, or getJson

Implementation details on the hot path:

  • Snapshot replacement swaps an immutable FlagSnapshot reference — concurrent readers keep the previous snapshot until the swap completes
  • SoasapClient is 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:

PlatformPath
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

Reads are decoupled from sync. Flag evaluation continues normally during outages because reads never depend on network availability. Only synchronization is affected during disconnects.

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.

ScenarioBehavior
API unavailableServes stale cached snapshot
SSE disconnectedKeeps last known snapshot; reconnects with backoff
First startup, no cacheReturns explicit defaults until first sync
Invalid SSE payloadPayload ignored; snapshot unchanged
Disk cache failureIn-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:

  1. Client construction loads the disk cache synchronously
  2. The SSE coroutine worker starts immediately — the constructor returns without awaiting network I/O
  3. The host keeps booting while synchronization completes in the background
  4. 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 SoasapClient at application or module scope; register as a DI singleton in Ktor, Spring, or Android.
  • Reuse the client — inject SoasapClientInterface into routes, services, ViewModels, and workers.
  • Store API keys securely — see Recommended configuration.
  • Enable preloadpreloadFlags = true for 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 (getBool for boolean flags)

Dashboard changes do not appear

  • Verify outbound HTTPS to api.soasap.com and 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 @SerialName when 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 onError in 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.