AG
TR

// writing / May 5, 2026

Kotlin Multiplatform in anger: shipping Muhabbet across Android, iOS, and backend

Muhabbet is a privacy-first Turkish messaging platform written end-to-end in Kotlin — Compose Multiplatform on mobile, Spring Boot on the backend, KMP shared module in the middle. This is what we learned shipping it.

Muhabbet — Turkish for conversation, intimate talk — is a privacy-first messaging platform built for Turkey. Spring Boot 4 + Kotlin 2.3 backend, Compose Multiplatform mobile (Android shipping, iOS in the bridging phase), PostgreSQL 16, Redis 7, MinIO for media, FCM for push, libsignal for end-to-end encryption, LiveKit for voice calls. The whole thing — from the database adapter to the chat bubble — is Kotlin. KVKK-compliant by design. Hexagonal architecture per module, modular monolith on the backend. The codebase is at 364 tests (314 backend + 23 mobile + 27 shared) and counting.

This piece is about what “Kotlin everywhere” actually buys you, and what it costs.

1. Why Kotlin everywhere

The pitch for Kotlin Multiplatform in 2026 is that the contract between client and server can be a real type, not a documentation page. We declare WsMessage once, in the shared/ module, as a sealed class with @SerialName discriminators. The backend serializes it. Both Android and iOS (eventually) deserialize it. If the server adds a new variant, the clients fail to compile until they handle it.

In practice this means:

  • No DTO drift. The MessageStatus enum (SENT, DELIVERED, READ) is the same enum on both sides. There is no version where the backend adds a FAILED state and the mobile client falls back to SENT silently.
  • No JSON-shape archaeology. kotlinx.serialization reads the same @Serializable class on the backend that the mobile client reads. The shape is the source of truth.
  • No JS interop layer. Compose Multiplatform compiles to native on iOS via Kotlin/Native and to Android JVM on Android. No JavaScript bridge, no React Native gymnastics, no Hermes engine in your stack.

The thing nobody tells you: the shared/ module is a discipline, not a checkbox. Anything you put in shared/ becomes API surface for two runtimes simultaneously. A change there is breaking-by-default in twice as many places. We have a hard rule that the shared module gets only domain models, protocol shapes, validation rules, and DTOs. No platform dependencies. No Spring annotations. No Compose imports. The instant something needs Context or @Service, it stays in its respective runtime.

2. The backend domain enum vs shared enum tension

Here is the trade-off that took us a few cycles to resolve. The backend has a ContentType enum in its domain layer: TEXT, IMAGE, AUDIO, VIDEO, FILE, LOCATION, .... The shared module also has a ContentType enum, used in the WebSocket protocol DTO.

For a while we thought: just import the shared enum in the backend domain. One source of truth.

But the backend domain layer must be framework-agnostic, and the shared module pulls in kotlinx.serialization annotations on every class. That breaks the hexagonal-architecture rule that the domain has zero external dependencies. So we kept them separate, with mapper extension functions (Message.toSharedMessage()) doing the conversion in one well-known place.

The cost of architectural purity is duplicated enums; the value is that the domain stays portable. We accept the cost.

3. Optimizing for Turkish mobile network reality

A Turkish messenger has to work on flaky 3G in tunnels and on a Mt. Erciyes ski lift. The default WhatsApp-style “websocket or bust” model breaks under those conditions.

What we shipped:

  • SQLDelight offline cache. Conversations and messages are cached locally first. The repository layer is cache-first: the UI reads from the local DB, and the network sync writes through. A user opening the app on the metro sees their last-known state instantly.
  • PendingMessage queue. Messages composed offline are written to a local SQLDelight table with a client-generated UUID. When the WebSocket reconnects, the queue is drained — and the UUID acts as an idempotency key, so duplicates from retries are deduped server-side.
  • Exponential backoff with jitter. WebSocket reconnect goes 1s → 2s → 4s → … → 30s, with random jitter so 100,000 phones coming back online after a network outage don’t synchronize their reconnect.
  • message.sendack(OK)message.newmessage.ack(DELIVERED)message.ack(READ). Five distinct WebSocket discriminators for one message lifecycle. Single tick = ServerAck OK. Double tick requires the recipient client to send message.ack back. We learned the hard way that single tick stuck forever is the symptom of the recipient app being closed — not a server bug.

The lesson here, which we keep relearning across projects: the network is not a sequence diagram. Every protocol that distinguishes legitimate retry from data corruption needs to model retries explicitly.

4. KVKK by design

KVKK (Türkiye’s GDPR-equivalent, Kişisel Verilerin Korunması Kanunu) is non-optional for a Turkish messenger. The defaults:

  • Soft delete with deleted_at for KVKK right to erasure. Hard-delete cascades through 8+ tables in this schema; we learned in another project that hard-deleting users is how you wipe legitimate audit trails. Soft-delete + a periodic purge job is the right answer.
  • No cross-border metadata. All data lives in Hetzner Helsinki for EU/EEA-adequacy, with explicit user consent before any FCM token leaves the box (FCM is Google, which is technically extra-EU; we surface this in the privacy dashboard).
  • Phone-number SHA-256 hashing for contact sync. The server never sees raw phone numbers in the contact-discovery flow. The mobile client normalizes to E.164 (normalizeToE164() in the shared util), hashes, and sends only the hash. The server matches hashes.
  • Privacy Dashboard. A KVKK rights screen in mobile settings: data export, account deletion, read receipts toggle, last-seen visibility, profile-photo visibility. Every right under KVKK has a one-tap UI surface.

Compliance is a UI feature, not just a policy document. A user who cannot find the export button does not have export rights in any practical sense.

5. The iOS reality check

Compose Multiplatform’s iOS story in 2026 is solid for UI. It is less solid for platform integrations. The pattern we settled on is expect/actual for everything platform-specific:

expect class CameraPicker {
    suspend fun pickPhoto(): ByteArray?
}

Android actual: ActivityResultContracts.TakePicture, dispatched through the activity. iOS actual: UIImagePickerController, bridged through Swift via Kotlin/Native interop.

What worked: AudioPlayer, AudioRecorder, ContactsProvider, PushTokenProvider, ImagePicker, FilePicker, ImageCompressor, CrashReporter, LocaleHelper. All of them shipped iOS-actual implementations.

What is still in bridging: LiveKit voice calls (LiveKit’s iOS Swift SDK has to be wrapped in Kotlin/Native bindings; the Android side ships today) and libsignal end-to-end encryption (libsignal-android has no Kotlin/Native build; on iOS we use NoOpKeyManager until we wire the Swift libsignal-client). The pattern: ship Android first with a NoOp fallback on iOS, swap the iOS NoOp for a real bridge in a later release.

The real surprise: KoinApplication composable starts a new Koin instance — and crashes on Activity recreate with KoinApplicationAlreadyStartedException. The fix is GlobalContext.getOrNull() ?: startKoin { ... }. We have a project-internal lessons-learned doc that is mostly composed of this kind of small, expensive paper cut.

What we’d do differently

  • Build the iOS actual for every expect on day one. Even if it is a NoOp. Discovering at the iOS-shipping milestone that 12 expects have no iOS counterpart is a four-week surprise.
  • Generate the API DTOs from a single OpenAPI source. We have a shared/ module; we should still have machine-generated checks that the backend Spring controllers and the mobile Ktor client agree on paths and response envelopes.
  • Treat WebSocket discriminator strings as protocol contracts, not Kotlin class names. We document them in docs/api-contract.md now; we should have started there.

Reading list

Repository: github.com/ahmetabdullahgultekin/Muhabbet. Source access is by request — email ahmetabdullahgultekin@gmail.com.

  • kotlin
  • multiplatform
  • mobile
  • kvkk