Architecting Sync: Building a Dual-Communication Pipeline Between Kotlin Jetpack Compose and Spring Boot

In modern full-stack development, mobile clients and backend ecosystems speak different languages but share the exact same structural expectations. A minor discrepancy in property naming conventions can silently break user flows, leaving a client app displaying fallback placeholders like "Placeholder User" while the backend sits completely unaware.

 

This guide breaks down the architecture of a robust dual-communication pipeline between a Kotlin (Android/Jetpack Compose) client application and a Java (Spring Boot) backend, with a specific focus on structural data alignment through Data Transfer Objects (DTOs), Retrofit mapping, and reactive client state streams.

 

The Architecture Blueprint

A reliable dual-communication pipeline is a closed loop. Data flows out from the client via network payloads, gets processed and returned by the backend, and flows into reactive UI streams inside the mobile app.

 

+------------------------------------------------------------------------+
|                            ANDROID CLIENT                              |
|                                                                        |
|  [Jetpack Compose View] <--- (StateFlow) <--- [MainViewModel]          |
|                                                     |                  |
|                                            [AuthRepository]            |
|                                                     |                  |
+------------------------------------------------------------------------+
                                                      |  (HTTP POST JSON)
                                                      v
+------------------------------------------------------------------------+
|                          SPRING BOOT BACKEND                           |
|                                                                        |
|  [AuthController] ---> [UserRepository] ---> [Database (UserEntity)]   |
|                                                                        |
+------------------------------------------------------------------------+

 
To prevent integration issues, alignment must be maintained across three core layers:

  1. The Backend Payload: Driven by dynamic maps or strict DTO schemas ( data attributes formatting)

  2. The Serialization Contract: Managed by Retrofit and Gson annotations (@SerializedName) on the mobile client

  3. The Reactive State Lifecycle: Managed via architecture-scoped StateFlow streams

 
Below are the full explanation based on each layer
 

Layer 1: The Spring Boot Payload Producer

The backend controller acts as the single source of truth for structural naming conventions. When a client performs an operation like logging in, the backend evaluates the database context via a repository layer and returns a payload mapping metadata keys to concrete variables.


package com.sentrypay.backend.Controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;

@RestController 
@RequestMapping("https://dev.to/api") 
public class AuthController {

    private final UserRepository userRepository;

    public AuthController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @PostMapping("https://dev.to/login") 
    public ResponseEntity<Map<String, String>> login(@RequestBody Map<String, String> loginRequest) {
        String username = loginRequest.get("username");
        String password = loginRequest.get("password");

        if (username == null || password == null) {
            return ResponseEntity.badRequest().body(Map.of("error", "Credentials required"));
        }

        Optional<UserEntity> userOptional = userRepository.findByEmail(username);

        if (userOptional.isPresent()) {
            UserEntity dbUser = userOptional.get();

            if (dbUser.getPassword().equals(password)) {
                // Producing the payload contract
                Map<String, String> responseBody = Map.of(
                    "token", "dummy-jwt-token", 
                    "antiPhishingName", dbUser.getAntiPhishingName(),
                    "userId", dbUser.getId().toString(),
                    "userName", dbUser.getFullname() // 💡 The Exact Contract Key
                );

                return ResponseEntity.ok(responseBody);
            }
        }
        return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
    }
}



 

Layer 2: The Client-Side Serialization Contract

When Kotlin receives the backend response, it utilizes JSON parsing frameworks like Google Gson or Kotlinx Serialization. The fields defined in the Java backend’s Map or DTO class must align with the target mapping annotations on the mobile side.
 
If the backend serves "userName", the client must explicitly parse it using @SerializedName("userName"). This step safely bridges backend conventions with clean local code style guidelines (e.g., camelCase variable names like userFullname).
 
In most cases , the data transfer cannot be sent back to the backend only because there is a mismatch on either of the client side or server side DTO class attributes. Therefore , it is important for developers to cross check the naming convention of each data attributes that sits within the data classes being declared!

 
Below are the Kotlin code example of data class naming convention that must match with springboot’s dto class:-


package com.example.sentrypaybank.backend.remote.data

import com.google.gson.annotations.SerializedName

data class LoginResponse(
    @SerializedName("token") 
    val token: String,

    @SerializedName("antiPhishingName") 
    val antiPhishingName: String,

    @SerializedName("userId") 
    val userId: Long,

    @SerializedName("userName") // 💡 Safely maps the Spring Boot metadata payload key
    val userFullname: String   //  Allows local variable customization
)


 

Layer 3: Injecting Network Models into Reactive State Flow

Once data passes parsing verification, it needs to be injected into state pipelines that survive lifecycle changes. Using Jetpack architecture standards, an asynchronous repository intercepts network data frames and pipes them into internal MutableStateFlow streams, which are exposed as read-only StateFlow interfaces to the user interface.
 
Below are the Kotlin code example that sits on Repository Layer :-

package com.example.sentrypaybank.backend.remote.data.repository

import com.example.sentrypaybank.backend.remote.data.LoginRequest
import com.example.sentrypaybank.backend.remote.data.LoginResponse
import com.example.sentrypaybank.backend.remote.data.SentryPayURLHost
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class AuthRepository {

    private val _currentUserId = MutableStateFlow<Long?>(null)
    private val _currentFullName = MutableStateFlow<String?>(null)

    // Public read-only exposure
    val currentUserId: StateFlow<Long?> = _currentUserId.asStateFlow()
    val currentFullName: StateFlow<String?> = _currentFullName.asStateFlow()

    private val apiService: SentryPayURLHost = SentryPayURLHost.instance


 suspend fun loginUser(usernameInput: String, userPasswordInput: String): Result<LoginResponse> {
        return try {
            val response = apiService.loginUser(LoginRequest(usernameInput.trim(), userPasswordInput))
            val body = response.body()

            if (response.isSuccessful && body != null) {
                // 💡 Hydrating the streams with metadata parsed from Layer 2
                _currentUserId.value = body.userId
                _currentFullName.value = body.userFullname
                Result.success(body)
            } else {
                Result.failure(Exception("Execution failed: ${response.code()}"))
            }
        } catch (e: Exception) {
            Result.failure(Exception("Network pipeline connection error", e))
        }
    }
}


 

The Shared View Model Context

To prevent real-time data loss, ensure that screen components extract streaming updates from a shared architectural lifecycle context rather than generating individual repository copies. Using ViewModel is one of the best ways to maintain the data persistency across different screen components at runtime , as the ViewModel components can be injected across multiple screen components.

The code example:-

class MainViewModel(private val authRepository: AuthRepository) : ViewModel() {
    val loggedInUserId: StateFlow<Long?> = authRepository.currentUserId
    val loggedInFullName: StateFlow<String?> = authRepository.currentFullName
}

 

Conclusion : The Golden Rules of Pipeline Alignment

To keep data flows synchronized across codebases, follow these best practices:

  1. Explicit Key Management:
    Avoid relying on automatic reflection formatting across development stacks. Use serialization annotations (@SerializedName in Kotlin, @JsonProperty in Java) to define payload fields explicitly.

  2. Shared Component Scope:
    Ensure your repository layer behaves as a single instance (Singleton pattern) via dependency injection tools like Hilt or shared Activity contexts. Re-instantiating state containers breaks data delivery paths.

  3. Nullable State Boundaries:
    Default state properties to null instead of hardcoded strings like “Sentry User” or “Full Name”. This helps your UI layers accurately detect loading states and apply fallback values only when data fetching is actually complete.
     

Thank you for reading! Let me know your thoughts on this and stay tuned for more software engineering rabbit hole topics ;D

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Automating Digital Product Creation: How I Built a 55-Maze Kids’ Activity Book in Minutes Using…

Related Posts