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
- Android Overview - Project structure and Hilt DI setup
- Android UI Development - ViewModels consuming repository data
- Android Security - Encrypted storage and certificate pinning
- Android Architecture - Repository pattern and Clean Architecture
- Android Testing - Testing repositories, DAOs, and network layer
Data and API Guidelines
- Database Design - Database schema design patterns
- Database ORM - ORM patterns and best practices
- API Design - RESTful API principles
- API Contracts - API contract design
- OpenAPI Frontend Integration - Type-safe client generation
- Caching - Caching strategies and patterns
Language Guidelines
- Kotlin Concurrency - Coroutines and Flow for async operations
External Resources
Summary
Key Takeaways
- Retrofit with coroutines - Clean asynchronous networking
- Room database - Type-safe local persistence with Flow
- Repository pattern - Abstract data sources for testability
- Offline-first - Cache first, sync in background
- DTOs separate from domain - Clean data mapping layer
- BigDecimal for currency - Exact precision for financial data
- Idempotency keys - Prevent duplicate payment submissions
- Error handling - Structured Result<T> or sealed classes
- Flow for reactive data - Observe database changes
- Hilt dependency injection - Clean dependency management
Next Steps: Explore Android Security for EncryptedSharedPreferences and certificate pinning implementation.