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
MessageStatusenum (SENT,DELIVERED,READ) is the same enum on both sides. There is no version where the backend adds aFAILEDstate and the mobile client falls back toSENTsilently. - No JSON-shape archaeology.
kotlinx.serializationreads the same@Serializableclass 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.
PendingMessagequeue. 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.send→ack(OK)→message.new→message.ack(DELIVERED)→message.ack(READ). Five distinct WebSocket discriminators for one message lifecycle. Single tick =ServerAck OK. Double tick requires the recipient client to sendmessage.ackback. 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_atfor 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
actualfor everyexpecton day one. Even if it is a NoOp. Discovering at the iOS-shipping milestone that 12expects 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.mdnow; we should have started there.
Reading list
- The Kotlin Multiplatform documentation — start with the “shared module” section.
- Compose Multiplatform release notes — the iOS stability story moved fast in 2025-2026.
- KVKK official text — read it once; it is shorter than GDPR.
Repository: github.com/ahmetabdullahgultekin/Muhabbet. Source access is by request — email ahmetabdullahgultekin@gmail.com.
- kotlin
- multiplatform
- mobile
- kvkk