Welcome everyone! Today we're exploring modern Android networking using Kotlin, with a focus on Clean Architecture and the MVVM pattern for enterprise-grade applications. We'll cover everything from building type-safe API clients with Ktor, to implementing the repository pattern with Kotlin Flows, to testing strategies that actually work at scale. Whether you're building your first Android app or refactoring a legacy codebase, these patterns will give you a solid foundation for maintainable, testable networking.
Let's start with why architecture matters for networking. Enterprise Android apps face some very real challenges. First, scale. You might have millions of users generating thousands of concurrent requests. Second, reliability. Users expect offline support, automatic retries, and graceful degradation when things go wrong. Third, maintainability. When you have ten or more engineers working on a multi-year codebase, you need clear boundaries and patterns that everyone can follow. Fourth, security. PII handling, certificate pinning, and compliance requirements are non-negotiable. And fifth, testability. If your business logic is tangled up with Android framework code, writing unit tests becomes nearly impossible. Poor architecture leads to spaghetti code, flaky tests, and those 3 AM production crashes nobody wants to debug.
list
Clean Architecture divides your app into three distinct layers. The Presentation layer handles UI rendering and state management. This is where your Jetpack Compose screens live, along with ViewModels that expose StateFlow for reactive UI updates. The Domain layer is the heart of your app. It contains pure business logic with zero Android dependencies. Use Cases enforce single responsibility, domain models represent your business entities, and repository interfaces define the contracts that the data layer must fulfill. The Data layer handles all external communication. This is where your Ktor API clients, Room DAOs, and repository implementations live. It also contains the mappers that convert between DTOs, database entities, and domain models.
cards
Looking at this diagram, you can see how data flows through the architecture. The Compose UI talks to the ViewModel, which calls Use Cases in the Domain layer. Use Cases then call Repository implementations in the Data layer. The key insight here is the Dependency Rule. Dependencies always point inward. The Domain layer has zero external dependencies. It defines repository interfaces, and the Data layer implements them. This inversion of control means you can swap out your networking library or database without touching any business logic. The Repository orchestrates between the API client and the local cache, mapping everything to Domain models before returning.
mermaid
For our networking stack, we're using two key libraries. First, Ktor as our HTTP client. It's built by JetBrains and designed from the ground up for Kotlin. Every function is a suspend function by default, so coroutines handle threading automatically. It uses a plugin architecture for features like authentication, logging, and retry logic. And it's multiplatform-ready if you're considering Kotlin Multiplatform down the road. Second, kotlinx.serialization for type-safe parsing. Unlike Gson, it generates code at compile time with no reflection overhead. This means faster startup, smaller APK size, and compile-time errors if your models don't match. It supports custom serializers for tricky types like dates and enums, and it's fully multiplatform compatible.
cards
Here's the project structure that enforces our architecture. Under the data package, we have remote for API interfaces and DTOs, local for Room DAOs and database entities, repositories for implementations, and mappers for converting between layers. The domain package contains pure Kotlin models, repository interfaces, and single-responsibility use cases. Notice that domain defines the repository interface, but data provides the implementation. This is Dependency Inversion in action. The presentation package has screens organized by feature, each with a ViewModel and Compose screen. This structure makes it immediately clear where any piece of code belongs and enforces the boundary between layers through package organization.
tree
Here's our API client implementation using Ktor. The UserApi class takes an HttpClient as a constructor parameter, making it easy to inject and test. Each method is a suspend function that maps directly to an HTTP operation. The getUser function calls GET on the users endpoint and deserializes the response body into a UserDto. The createUser function sends a POST request with a JSON body. Notice how clean this is compared to Retrofit annotations. With Ktor, you write regular Kotlin code. The HttpClient handles serialization, content type headers, and error responses through its plugin system. Every function here is coroutine-aware, so there's no callback hell or RxJava chain to manage.
code
The repository is where the magic happens. Looking at this code, the getUser function returns a Flow of Resource, which is our sealed class for loading, success, and error states. First, we emit a Loading state. Then we check the local cache. If there's cached data, we map it to a domain model and emit a Success immediately. The user sees content within milliseconds, no loading spinner needed. Then we fetch fresh data from the network, save it to the cache, and emit another Success with the updated data. The catch operator handles any exceptions, mapping them to domain errors. And flowOn moves all of this work to the IO dispatcher. Looking at the table, this pattern gives us instant UI through cache-first loading, reactive updates as data arrives, background threading with flowOn, and centralized error handling with catch.
code table
The ViewModel ties everything together. It takes a GetUserUseCase as a dependency, not the repository directly. This keeps the ViewModel thin and focused on UI state management. We expose a single StateFlow of ProfileState, which is a sealed interface with Loading, Success, and Error variants. When loadProfile is called, we launch a coroutine in viewModelScope, which automatically cancels when the ViewModel is cleared. We collect emissions from the use case and map each Resource to the corresponding ProfileState. Notice we're using data object for Loading instead of a regular object. This is a Kotlin feature that gives us proper equality semantics. Compose collects this StateFlow and renders the appropriate UI for each state. No mutable state leaks outside the ViewModel.
code
Error handling needs to be intentional in enterprise apps. On the left, we have a sealed error hierarchy. DomainError is our base class with specific subtypes for network failures, server errors, authentication issues, and validation problems. This gives us exhaustive when expressions. The compiler catches any missing cases. On the right, we have a retry extension function for Flow. It uses retryWhen with exponential backoff. The delay doubles each attempt: one second, two seconds, four seconds. It only retries on IOException, which covers transient network failures, and caps at three attempts. This is cancellation-safe because delay is a suspend function. Map HTTP status codes to domain errors in the data layer. Never expose raw exceptions to the presentation layer.
cards
Caching is critical for a good user experience. We use three layers of caching. First, Room Persistence for long-term structured storage. User profiles, content lists, and settings all go into Room. It supports offline reads and has migration support for schema changes as your app evolves. Second, In-Memory Cache for session-scoped fast access. LruCache handles frequently accessed data with TTL-based expiry, typically five to thirty minutes. It's thread-safe with ConcurrentHashMap backing. Third, the Hybrid Strategy that combines both. The pattern is simple: emit cached data, fetch fresh, then update. Background sync keeps data fresh when online, timestamp-based conflict resolution handles edge cases, and an offline queue handles write operations when the network is unavailable. The golden rule is always emit cached data first. Users should see content instantly.
cards
Hilt provides compile-time dependency injection for Android. Looking at this code, the NetworkModule is installed in the SingletonComponent, meaning these dependencies live for the entire app lifetime. The HttpClient gets content negotiation and logging plugins, plus HTTP timeouts of ten seconds for connection and thirty seconds for requests. The UserApi depends on the HttpClient, so Hilt resolves the dependency graph automatically. The DomainModule is installed in ViewModelComponent, scoped to the ViewModel lifecycle. Use cases are created fresh for each ViewModel instance. Looking at the scoping table, SingletonComponent is for app-lifetime objects like HTTP clients and databases. ViewModelComponent is for screen-scoped objects like use cases and repositories. ActivityComponent is for navigation and UI state. Getting scoping right prevents memory leaks and stale data.
code table
Testing is where good architecture pays off. Looking at this test class, we're using fake implementations instead of mocking frameworks. FakeUserApi and FakeUserDao are simple in-memory implementations that we control completely. The first test verifies our cache-first behavior. We insert a cached user named Alice, set up the API to return Bob, then collect from the repository. We expect Loading first, then a Success with Alice from cache, then a Success with Bob from the network. Turbine's test extension makes this assertion pattern clean and readable. The second test covers network failures. We tell the fake API to throw an IOException, then verify we get Loading followed by an Error. Notice how simple these tests are. No mocking framework, no Android dependencies, just pure Kotlin. Use Turbine for Flow assertions and fakes over mocks. They're simpler, faster, and more reliable.
code
Let me share some common pitfalls that cost teams weeks of refactoring. God Repositories that touch multiple domains. Split them by bounded context. ViewModel calling APIs directly, which bypasses your domain layer entirely. Always route through Use Cases. Mutable DTOs using var properties lead to subtle state bugs. Use data classes with val. Ignoring coroutine cancellation causes memory leaks. Always scope to viewModelScope or lifecycleScope. The most common mistake: treating DTOs as domain models. When the API contract changes, it shouldn't break your UI layer. Always use mappers at every boundary. No offline support means your app crashes on airplane mode. Hardcoded base URLs make it impossible to switch between dev and prod. And missing request deduplication means rapid taps trigger duplicate API calls. Use SharedFlow or distinctUntilChanged to prevent this.
list
Security is non-negotiable for production apps. On the left, TLS and certificate pinning. This prevents man-in-the-middle attacks even on compromised networks. On Android API twenty-four and above, you can use the Network Security Config XML to declare your pins. Always pin the SHA-256 of the public key, always include backup pins, and set an expiration date so you're forced to rotate before certificates expire. On the right, timeouts and connection pooling. These prevent resource exhaustion and Application Not Responding errors. Set a connect timeout of ten seconds, request timeout of thirty seconds, and configure your connection pool with a maximum of twenty connections and a five-minute keep-alive. Android kills your app if the main thread blocks for five seconds, so proper timeout configuration is critical for preventing ANR crashes in production.
cards
Let's wrap up with the key takeaways. Separate your concerns into Presentation, Domain, and Data layers with strict boundaries. Domain models are not DTOs. Always map at every boundary to decouple your API contract from your business logic. Use cache-first loading to emit cached data immediately and fetch fresh data in the background. Sealed classes give you type-safe error handling and UI state management across every layer. Test with fakes, not mocking frameworks. They're simpler, faster, and catch more real bugs. Coroutines and Flow give you reactive, cancellable, lifecycle-aware networking. Get your Hilt scoping right: Singleton for clients, ViewModel-scoped for use cases. And finally, security by default: certificate pinning, proper timeouts, and never hardcode secrets. Thank you!
list