Skip to main content

Android Data & Networking

Key Concepts

Effective Android data management relies on three foundational patterns: offline-first architecture ensures the app remains functional without network connectivity by prioritizing local data storage (Room database) and syncing with remote APIs when available; the repository pattern abstracts data sources behind a clean interface, allowing ViewModels to request data without knowing whether it comes from a local cache or network call; and reactive data streams with Kotlin Flow enable the UI to automatically update when underlying data changes. This guide demonstrates how these patterns work together to create responsive, reliable applications. For architectural context, see Android Architecture, and for testing these patterns, refer to Android Testing.

Overview

This guide covers data management and networking best practices for Android applications. We focus on RESTful API integration with Retrofit, local persistence with Room, repository pattern implementation, offline-first strategies, and synchronization mechanisms.

What This Guide Covers

  • Networking with Retrofit: REST APIs, error handling, interceptors
  • Room Database: Entities, DAOs, migrations, Flow
  • Repository Pattern: Abstracting data sources for testability
  • Offline-First Architecture: Local-first design, sync strategies
  • Data Mapping: DTOs to domain models, type-safe transformations

Networking Architecture

Networking Layer Diagram


Retrofit Networking

Retrofit Setup

// data/remote/api/PaymentApi.kt
package com.bank.paymentapp.data.remote.api

import com.bank.paymentapp.data.remote.dto.PaymentDto
import com.bank.paymentapp.data.remote.dto.CreatePaymentRequest
import retrofit2.Response
import retrofit2.http.*

interface PaymentApi {
@GET("v1/payments")
suspend fun getPayments(
@Query("limit") limit: Int = 50
): Response<List<PaymentDto>>

@GET("v1/payments/{id}")
suspend fun getPayment(
@Path("id") id: String
): Response<PaymentDto>

@POST("v1/payments")
suspend fun createPayment(
@Body request: CreatePaymentRequest
): Response<PaymentDto>

@POST("v1/payments/{id}/cancel")
suspend fun cancelPayment(
@Path("id") id: String
): Response<PaymentDto>
}

Network Module (Hilt)

// di/NetworkModule.kt
package com.bank.paymentapp.di

import com.bank.paymentapp.BuildConfig
import com.bank.paymentapp.data.remote.api.PaymentApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
})
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
// Add auth token if available
.build()
chain.proceed(request)
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

@Provides
@Singleton
fun providePaymentApi(retrofit: Retrofit): PaymentApi {
return retrofit.create(PaymentApi::class.java)
}
}

Request/Response Flow


Room Database

Room Architecture

Entity Definition

// data/local/entities/PaymentEntity.kt
package com.bank.paymentapp.data.local.entities

import androidx.room.Entity
import androidx.room.PrimaryKey
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import java.math.BigDecimal

@Entity(tableName = "payments")
data class PaymentEntity(
@PrimaryKey
val id: String,
val recipientName: String,
val recipientAccountNumber: String,
val amount: String, // BigDecimal stored as String
val currency: String,
val status: String,
val createdAt: Long,
val completedAt: Long?,
val description: String?,
val syncedAt: Long? // Track last sync
) {
fun toDomain(): Payment {
return Payment(
id = id,
recipientName = recipientName,
recipientAccountNumber = recipientAccountNumber,
amount = BigDecimal(amount),
currency = currency,
status = PaymentStatus.valueOf(status),
createdAt = createdAt,
completedAt = completedAt,
description = description
)
}

companion object {
fun fromDomain(payment: Payment): PaymentEntity {
return PaymentEntity(
id = payment.id,
recipientName = payment.recipientName,
recipientAccountNumber = payment.recipientAccountNumber,
amount = payment.amount.toString(),
currency = payment.currency,
status = payment.status.name,
createdAt = payment.createdAt,
completedAt = payment.completedAt,
description = payment.description,
syncedAt = System.currentTimeMillis()
)
}
}
}

DAO (Data Access Object)

// data/local/dao/PaymentDao.kt
package com.bank.paymentapp.data.local.dao

import androidx.room.*
import com.bank.paymentapp.data.local.entities.PaymentEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface PaymentDao {
@Query("SELECT * FROM payments ORDER BY createdAt DESC")
fun getAllFlow(): Flow<List<PaymentEntity>>

@Query("SELECT * FROM payments ORDER BY createdAt DESC")
suspend fun getAll(): List<PaymentEntity>

@Query("SELECT * FROM payments WHERE id = :id")
suspend fun getById(id: String): PaymentEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(payment: PaymentEntity)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(payments: List<PaymentEntity>)

@Delete
suspend fun delete(payment: PaymentEntity)

@Query("DELETE FROM payments")
suspend fun deleteAll()

@Query("SELECT * FROM payments WHERE syncedAt IS NULL OR syncedAt < :timestamp")
suspend fun getUnsyncedPayments(timestamp: Long): List<PaymentEntity>
}

Database Setup

// data/local/PaymentDatabase.kt
package com.bank.paymentapp.data.local

import androidx.room.Database
import androidx.room.RoomDatabase
import com.bank.paymentapp.data.local.dao.PaymentDao
import com.bank.paymentapp.data.local.entities.PaymentEntity

@Database(
entities = [PaymentEntity::class],
version = 1,
exportSchema = true
)
abstract class PaymentDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao
}

Database Module (Hilt)

// di/DatabaseModule.kt
package com.bank.paymentapp.di

import android.content.Context
import androidx.room.Room
import com.bank.paymentapp.data.local.PaymentDatabase
import com.bank.paymentapp.data.local.dao.PaymentDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

@Provides
@Singleton
fun providePaymentDatabase(
@ApplicationContext context: Context
): PaymentDatabase {
return Room.databaseBuilder(
context,
PaymentDatabase::class.java,
"payment_database"
)
.fallbackToDestructiveMigration()
.build()
}

@Provides
fun providePaymentDao(database: PaymentDatabase): PaymentDao {
return database.paymentDao()
}
}

Repository Pattern

Repository Interface (Domain)

// domain/repository/PaymentRepository.kt
package com.bank.paymentapp.domain.repository

import com.bank.paymentapp.domain.model.Payment
import kotlinx.coroutines.flow.Flow

interface PaymentRepository {
fun getPaymentsFlow(): Flow<List<Payment>>
suspend fun getPayments(): Result<List<Payment>>
suspend fun getPayment(id: String): Result<Payment>
suspend fun createPayment(payment: Payment): Result<Payment>
suspend fun syncPayments(): Result<Unit>
}

Repository Implementation

// data/repository/PaymentRepositoryImpl.kt
package com.bank.paymentapp.data.repository

import com.bank.paymentapp.data.local.dao.PaymentDao
import com.bank.paymentapp.data.local.entities.PaymentEntity
import com.bank.paymentapp.data.remote.api.PaymentApi
import com.bank.paymentapp.data.remote.dto.CreatePaymentRequest
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.repository.PaymentRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class PaymentRepositoryImpl @Inject constructor(
private val paymentApi: PaymentApi,
private val paymentDao: PaymentDao
) : PaymentRepository {

// MARK: - Offline-First: Flow from local database

override fun getPaymentsFlow(): Flow<List<Payment>> {
return paymentDao.getAllFlow().map { entities ->
entities.map { it.toDomain() }
}
}

// MARK: - Fetch with cache-first strategy

override suspend fun getPayments(): Result<List<Payment>> {
return try {
// 1. Return cached data immediately
val cached = paymentDao.getAll().map { it.toDomain() }

// 2. Fetch fresh data in background
syncPayments()

Result.success(cached)
} catch (e: Exception) {
Result.failure(e)
}
}

override suspend fun getPayment(id: String): Result<Payment> {
return try {
// Try local first
val cached = paymentDao.getById(id)
if (cached != null) {
return Result.success(cached.toDomain())
}

// Fallback to network
val response = paymentApi.getPayment(id)
if (response.isSuccessful && response.body() != null) {
val payment = response.body()!!.toDomain()
paymentDao.insert(PaymentEntity.fromDomain(payment))
Result.success(payment)
} else {
Result.failure(Exception("Payment not found"))
}
} catch (e: Exception) {
Result.failure(e)
}
}

// MARK: - Writes go to server first, then cache

override suspend fun createPayment(payment: Payment): Result<Payment> {
return try {
val request = CreatePaymentRequest.fromDomain(payment)
val response = paymentApi.createPayment(request)

if (response.isSuccessful && response.body() != null) {
val createdPayment = response.body()!!.toDomain()
paymentDao.insert(PaymentEntity.fromDomain(createdPayment))
Result.success(createdPayment)
} else {
Result.failure(Exception("Failed to create payment"))
}
} catch (e: Exception) {
Result.failure(e)
}
}

// MARK: - Background Sync

override suspend fun syncPayments(): Result<Unit> {
return try {
val response = paymentApi.getPayments()
if (response.isSuccessful && response.body() != null) {
val payments = response.body()!!.map { it.toDomain() }
val entities = payments.map { PaymentEntity.fromDomain(it) }
paymentDao.insertAll(entities)
Result.success(Unit)
} else {
Result.failure(Exception("Sync failed"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

Repository Data Flow


Data Transfer Objects (DTOs)

Payment DTO

// data/remote/dto/PaymentDto.kt
package com.bank.paymentapp.data.remote.dto

import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.google.gson.annotations.SerializedName
import java.math.BigDecimal

data class PaymentDto(
@SerializedName("id")
val id: String,
@SerializedName("recipient_name")
val recipientName: String,
@SerializedName("recipient_account_number")
val recipientAccountNumber: String,
@SerializedName("amount")
val amount: String, // BigDecimal as String from API
@SerializedName("currency")
val currency: String,
@SerializedName("status")
val status: String,
@SerializedName("created_at")
val createdAt: Long,
@SerializedName("completed_at")
val completedAt: Long?,
@SerializedName("description")
val description: String?
) {
fun toDomain(): Payment {
return Payment(
id = id,
recipientName = recipientName,
recipientAccountNumber = recipientAccountNumber,
amount = BigDecimal(amount),
currency = currency,
status = PaymentStatus.valueOf(status),
createdAt = createdAt,
completedAt = completedAt,
description = description
)
}
}

data class CreatePaymentRequest(
@SerializedName("recipient_name")
val recipientName: String,
@SerializedName("recipient_account_number")
val recipientAccountNumber: String,
@SerializedName("amount")
val amount: String,
@SerializedName("currency")
val currency: String,
@SerializedName("description")
val description: String?,
@SerializedName("idempotency_key")
val idempotencyKey: String
) {
companion object {
fun fromDomain(payment: Payment): CreatePaymentRequest {
return CreatePaymentRequest(
recipientName = payment.recipientName,
recipientAccountNumber = payment.recipientAccountNumber,
amount = payment.amount.toString(),
currency = payment.currency,
description = payment.description,
idempotencyKey = java.util.UUID.randomUUID().toString()
)
}
}
}

Offline-First Strategy

Sync Strategy


Common Mistakes

Don't: Use Float/Double for Currency

// BAD: Floating point precision errors
val amount: Double = 0.1 + 0.2 // 0.30000000000000004

// GOOD: Use BigDecimal for currency
val amount: BigDecimal = BigDecimal("0.1") + BigDecimal("0.2") // 0.3 exact

Don't: Block Main Thread with Database Operations

// BAD: Blocking main thread causes ANR
fun loadPayments(): List<Payment> {
return runBlocking { // BLOCKS UI!
paymentDao.getAll().map { it.toDomain() }
}
}

// GOOD: Use coroutines in ViewModel
fun loadPayments() {
viewModelScope.launch {
val payments = paymentDao.getAll().map { it.toDomain() }
_state.value = PaymentListState.Success(payments)
}
}

Don't: Ignore Idempotency for Payments

// BAD: No idempotency key (duplicate payments!)
suspend fun createPayment(payment: Payment) {
paymentApi.createPayment(CreatePaymentRequest.fromDomain(payment))
}

// GOOD: Include idempotency key
data class CreatePaymentRequest(
// ... other fields
val idempotencyKey: String = UUID.randomUUID().toString()
)

Code Review Checklist

Security (Block PR)

  • No API keys in code - Use BuildConfig
  • Certificate pinning enabled - For production
  • Encrypted storage for tokens - EncryptedSharedPreferences
  • No sensitive data logged - Account numbers, amounts, tokens

Watch For (Request Changes)

  • BigDecimal for currency - Never Float/Double
  • Suspend functions for Room - All DAO operations
  • Idempotency keys for writes - Prevent duplicate payments
  • Error handling present - All network calls wrapped in try/catch
  • Flow for reactive data - Use Flow, not LiveData
  • DTOs separate from domain - Proper layer separation

Best Practices

  • Repository pattern - Abstract data sources
  • Offline-first reads - Return cached data immediately
  • Server-first writes - Write to server, then cache
  • Background sync - WorkManager for periodic sync
  • Type-safe DTOs - Use data classes with proper serialization
  • Proper error types - Structured Result<T> or sealed classes

Further Reading

Android Framework Guidelines

Data and API Guidelines

Language Guidelines

External Resources


Summary

Key Takeaways

  1. Retrofit with coroutines - Clean asynchronous networking
  2. Room database - Type-safe local persistence with Flow
  3. Repository pattern - Abstract data sources for testability
  4. Offline-first - Cache first, sync in background
  5. DTOs separate from domain - Clean data mapping layer
  6. BigDecimal for currency - Exact precision for financial data
  7. Idempotency keys - Prevent duplicate payment submissions
  8. Error handling - Structured Result<T> or sealed classes
  9. Flow for reactive data - Observe database changes
  10. Hilt dependency injection - Clean dependency management

Next Steps: Explore Android Security for EncryptedSharedPreferences and certificate pinning implementation.