Popular Searches
Popular Course Categories
Popular Courses

Structuring Maintainable and Testable Android Applications

Structuring Maintainable and Testable Android Applications

Android Architecture and State Management

Structuring Maintainable and Testable Android Applications

Course: Android App Development

Topic: Structuring Maintainable and Testable Android Applications

Course Link: Android App Development Course

Demo Link: Register for a Course Demo

Website: JustAcademy


1. Introduction

Structuring maintainable and testable Android applications means organizing source code, components, dependencies, and responsibilities so that an application remains easy to understand, modify, debug, extend, and verify as it grows. A well-structured Android project reduces code duplication, limits the impact of changes, and allows developers to test individual components without launching the entire application.

Android applications often combine user interfaces, business rules, network requests, local databases, authentication, background tasks, and device-specific functionality. When these responsibilities are placed in a single Activity or Fragment, the code becomes tightly coupled and difficult to maintain. A clear architecture separates these responsibilities into components with defined roles.

Modern Android architecture commonly uses a UI layer and a data layer, with an optional domain layer for reusable or complex business logic. ViewModels, repositories, Kotlin coroutines, Flow, dependency injection, and automated tests help implement this structure effectively.

2. Learning Objectives

  • Understand maintainability, testability, and separation of concerns.
  • Organize Android applications into clear architectural layers.
  • Use ViewModels, repositories, interfaces, and dependency injection appropriately.
  • Apply unidirectional data flow and a single source of truth.
  • Write unit tests for business logic, ViewModels, and repositories.
  • Use fakes and other test doubles to isolate application components.
  • Identify common architecture problems and improve existing projects.

3. What Is Maintainability?

Maintainability is the ease with which developers can understand, correct, improve, and extend an application after its initial implementation.

A maintainable application has clear responsibilities, consistent naming, limited duplication, readable code, and predictable interactions between components. It allows a developer to add a feature without unexpectedly breaking unrelated functionality.

Characteristics of Maintainable Code

  • Readability: Classes and functions communicate their purpose through clear names and straightforward logic.
  • Modularity: Responsibilities are divided into manageable components.
  • Low coupling: Components depend on abstractions rather than unnecessary implementation details.
  • High cohesion: Each class focuses on a closely related set of responsibilities.
  • Reusability: Common functionality can be reused without copying code.
  • Consistency: Similar features follow similar architectural conventions.
  • Documentation: Important decisions and complex behavior are explained where needed.

4. What Is Testability?

Testability is the degree to which a component can be verified independently and reliably. A testable component accepts its dependencies through clearly defined interfaces or constructors and does not require unnecessary access to Android framework objects, a live network, or a production database.

For example, a price calculator that accepts a price and discount percentage can be tested as an ordinary Kotlin function. If the same calculation is hidden inside an Activity that also reads views, accesses a database, and displays Toast messages, testing the calculation becomes more complicated.

Benefits of Testable Applications

  • Defects can be discovered before release.
  • Refactoring becomes safer because automated tests verify existing behavior.
  • Business rules can be tested without launching an emulator.
  • Network and database behavior can be simulated using test doubles.
  • Regression testing becomes faster and more repeatable.
  • Teams can modify different components with fewer unintended side effects.

5. Separation of Concerns

Separation of concerns means assigning different responsibilities to different classes, packages, or modules. Each component should have a clearly defined purpose and expose only the functionality that other components need.

For example, a login screen should collect credentials and display the login result. A repository should coordinate access to authentication data sources. A validation function should check whether the entered values satisfy the application's rules.

Example of Poor Structure

class LoginActivity : AppCompatActivity() {
    fun login(email: String, password: String) {
        // Validate the email and password.
        // Build a network request.
        // Execute the request.
        // Save the authentication token.
        // Update the UI and display messages.
    }
}

This example puts several responsibilities into one Activity. Changes to the authentication API, validation rules, or UI can all require changes to the same class. It is also difficult to test the login logic without Android dependencies.

Improved Structure

UI (Activity or Compose Screen)
          |
          v
ViewModel
          |
          v
Use Case (optional)
          |
          v
Repository Interface
          |
          v
Repository Implementation
       /       \
      v         v
Remote API   Local Database

The exact structure depends on the application. Small projects do not need dozens of layers, but even small applications benefit from keeping UI code separate from data access and reusable business rules.

6. Recommended Android Architecture Layers

Layer Main Responsibility Typical Components
UI or Presentation Displays state and handles user interactions. Activities, Fragments, Compose screens, ViewModels, UI state models
Domain (optional) Encapsulates reusable or complex application rules. Use cases, domain models, business-rule interfaces
Data Provides application data and coordinates data sources. Repositories, data sources, DTOs, database entities, API services
Infrastructure Provides supporting technical capabilities. Dependency injection, logging, networking configuration

6.1 UI Layer

The UI layer displays information and forwards user actions to a state holder, commonly a ViewModel. It observes UI state and renders loading, success, empty, and error conditions.

In a Jetpack Compose application, a screen can observe state from a ViewModel using lifecycle-aware collection:

@Composable
fun ProductScreen(viewModel: ProductViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is ProductUiState.Loading -> {
            CircularProgressIndicator()
        }
        is ProductUiState.Success -> {
            Text("Products: ${state.products.size}")
        }
        is ProductUiState.Error -> {
            Text(state.message)
        }
    }
}

This example assumes that the relevant Compose, lifecycle, and state imports are included. The screen renders state instead of directly accessing the database or making network requests.

6.2 Domain Layer

The domain layer is optional. It is useful when business rules are complex, must be reused by multiple ViewModels, or need to be represented independently of UI and data-source implementation details.

A use case usually represents one meaningful operation, such as calculating a shopping cart total, validating an order, or retrieving a user's eligible offers.

class CalculateDiscount {
    operator fun invoke(
        price: Double,
        discountPercent: Double
    ): Double {
        require(price >= 0) { "Price cannot be negative" }
        require(discountPercent in 0.0..100.0) {
            "Discount must be between 0 and 100"
        }
        return price * discountPercent / 100
    }
}

Because this class does not depend on Android framework classes, it can be tested using ordinary JVM unit tests.

6.3 Data Layer

The data layer exposes application data through repositories and coordinates sources such as a REST API, Room database, DataStore, or in-memory cache. The UI should normally request data through a repository or a suitable domain operation instead of directly accessing these sources.

Repositories also provide a useful boundary for deciding how data is loaded, refreshed, cached, or combined from different sources.

7. Organizing Packages and Files

A package structure should help developers locate related code and understand dependencies. One possible structure for a product application is:

com.example.shop
├── app
│   ├── MainActivity.kt
│   └── navigation
├── core
│   ├── network
│   ├── database
│   └── common
├── feature
│   └── products
│       ├── ui
│       │   ├── ProductScreen.kt
│       │   ├── ProductViewModel.kt
│       │   └── ProductUiState.kt
│       ├── domain
│       │   └── GetProducts.kt
│       └── data
│           ├── ProductRepository.kt
│           ├── ProductRepositoryImpl.kt
│           ├── ProductApi.kt
│           └── ProductDto.kt
└── di
    └── AppModule.kt

This is an illustrative structure, not a mandatory directory layout. Small applications may use simpler packages. Larger applications can organize code by feature and introduce Gradle modules when independent compilation, ownership, or dependency boundaries provide real value.

Package Organization Best Practices

  • Use descriptive package and class names.
  • Group closely related files together.
  • Avoid creating packages that contain only one trivial class unless the separation adds clarity.
  • Keep feature-specific code close to the feature it supports.
  • Move shared functionality into common modules only when it is genuinely shared.
  • Avoid circular dependencies between modules.
  • Keep internal implementation details private wherever possible.

8. Applying the Repository Pattern

The repository pattern separates the application's data access requirements from the implementation of network and database operations. It provides a stable API that the ViewModel or domain layer can use.

Step 1: Define a Model

data class Product(
    val id: Int,
    val name: String,
    val price: Double
)

Step 2: Define a Repository Interface

interface ProductRepository {
    suspend fun getProducts(): List<Product>
}

Step 3: Implement the Repository

class DefaultProductRepository(
    private val api: ProductApi
) : ProductRepository {

    override suspend fun getProducts(): List<Product> {
        return api.getProducts().map { dto ->
            Product(
                id = dto.id,
                name = dto.name,
                price = dto.price
            )
        }
    }
}

Here, ProductApi represents an API service whose response DTOs can be mapped to the application's Product model. The interface allows the ViewModel to depend on the repository contract rather than the concrete network implementation.

Why Use a Repository?

  • It centralizes data-access decisions.
  • It reduces direct dependencies on Retrofit, Room, or other data sources.
  • It provides a clear location for mapping data models.
  • It makes unit testing easier because a fake repository can replace the real one.
  • It supports future changes, such as adding caching or offline access, with fewer UI changes.

9. Using ViewModels to Separate UI and Logic

A ViewModel manages UI-related state and prepares data for display. It should not contain direct view references or depend on an Activity or Fragment. A ViewModel can request data through a repository and expose an observable state object.

sealed interface ProductUiState {
    data object Loading : ProductUiState
    data class Success(
        val products: List<Product>
    ) : ProductUiState
    data class Error(
        val message: String
    ) : ProductUiState
}

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow<ProductUiState>(ProductUiState.Loading)

    val uiState: StateFlow<ProductUiState> =
        _uiState.asStateFlow()

    fun loadProducts() {
        viewModelScope.launch {
            _uiState.value = ProductUiState.Loading

            try {
                _uiState.value =
                    ProductUiState.Success(repository.getProducts())
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value =
                    ProductUiState.Error("Unable to load products")
            }
        }
    }
}

The example assumes the required AndroidX Lifecycle and Kotlin coroutine imports. A real implementation should use an appropriate error-reporting strategy and avoid exposing sensitive internal exception details to users. The repository should make blocking operations main-safe by moving them to an appropriate dispatcher.

Notice that the ViewModel receives its repository through the constructor. This makes the dependency explicit and allows a test to provide a fake implementation.

10. Unidirectional Data Flow and Single Source of Truth

Unidirectional Data Flow (UDF)

Unidirectional data flow means that UI actions travel toward the state holder, while state updates travel back toward the UI. This creates a predictable relationship between user actions and rendered output.

  1. The user performs an action, such as tapping Refresh.
  2. The UI forwards the action to the ViewModel.
  3. The ViewModel requests the required operation.
  4. The repository retrieves or updates data.
  5. The ViewModel publishes the updated UI state.
  6. The UI renders the latest state.

Single Source of Truth (SSOT)

The single source of truth principle means that one clearly defined owner controls updates to a particular piece of data. For example, a repository may expose a stream of locally stored products, while database transactions remain responsible for updating the stored records.

SSOT reduces conflicting copies of mutable state. It is particularly useful when multiple screens need to display the same information.

11. Dependency Injection

Dependency injection (DI) means supplying a class with the objects it needs rather than making the class create those objects internally. Constructor injection is usually the clearest approach for ordinary Kotlin classes.

Without Dependency Injection

class ProductViewModel : ViewModel() {
    private val repository =
        DefaultProductRepository(RealProductApi())
}

This design tightly couples the ViewModel to the concrete repository and API implementation. Replacing the API for a unit test becomes difficult.

With Constructor Injection

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel()

The caller supplies the repository. In a production application, dependencies may be provided through Hilt or a small manual dependency container. For a unit test, the caller can provide a fake repository.

Benefits of Dependency Injection

  • Dependencies are explicit and easier to understand.
  • Components become easier to test in isolation.
  • Implementations can be replaced without rewriting dependent classes.
  • Object creation and lifecycle management can be centralized.
  • Large projects can standardize dependency setup across features.

DI frameworks are helpful when a project has enough dependencies to justify them. They are not a requirement for every small application.

12. Writing Unit Tests for Business Logic

Unit tests verify small pieces of logic independently. They are especially suitable for validation rules, calculations, formatting, transformations, and other deterministic behavior.

For the CalculateDiscount class, a simple test can verify the expected result:

import org.junit.Assert.assertEquals
import org.junit.Test

class CalculateDiscountTest {

    private val calculateDiscount = CalculateDiscount()

    @Test
    fun `calculates discount correctly`() {
        val result = calculateDiscount(
            price = 1000.0,
            discountPercent = 10.0
        )

        assertEquals(100.0, result, 0.001)
    }

    @Test(expected = IllegalArgumentException::class)
    fun `rejects invalid discount`() {
        calculateDiscount(
            price = 1000.0,
            discountPercent = 120.0
        )
    }
}

These tests check both a normal case and an invalid input. Additional tests should cover zero prices, zero discounts, and a full 100 percent discount.

13. Testing ViewModels with Fake Repositories

A fake is a lightweight working implementation of an interface, created specifically for tests. It can return controlled data without requiring a live API or database.

class FakeProductRepository(
    private val products: List<Product> = emptyList(),
    private val failure: Exception? = null
) : ProductRepository {

    override suspend fun getProducts(): List<Product> {
        failure?.let { throw it }
        return products
    }
}

A ViewModel test can use this fake to control the result of the repository. For coroutine-based tests, use the Kotlin coroutine test library and a test dispatcher when needed. If the ViewModel uses viewModelScope, make sure coroutine execution is controlled by the test environment.

@OptIn(ExperimentalCoroutinesApi::class)
class ProductViewModelTest {

    @Test
    fun `loads products successfully`() = runTest {
        val expected = listOf(
            Product(1, "Keyboard", 1500.0),
            Product(2, "Mouse", 700.0)
        )
        val viewModel = ProductViewModel(
            FakeProductRepository(expected)
        )

        viewModel.loadProducts()
        advanceUntilIdle()

        assertEquals(
            ProductUiState.Success(expected),
            viewModel.uiState.value
        )
    }
}

This illustrates the testing approach; the complete test setup should include the required coroutine-test imports and a Main dispatcher rule or equivalent test dispatcher configuration when the code uses viewModelScope. Keep test dispatchers consistent so asynchronous work finishes predictably.

14. Unit, Integration, and UI Testing

Test Type Purpose Example
Unit test Checks one component in isolation. Verify a discount calculation or ViewModel state.
Integration test Checks interactions between connected components. Verify repository behavior with a test database.
UI test Checks user-visible behavior and interactions. Verify that tapping Refresh displays the loaded products.
End-to-end test Checks a complete user workflow. Sign in, browse products, and complete a purchase in a test environment.
Performance test Measures speed, startup, rendering, or resource use. Measure app startup or scrolling performance.

Use the test level that best matches the behavior being verified. Not every function needs an emulator-based test. Pure business rules are generally faster to test as JVM unit tests, while device-dependent behavior may require instrumentation or UI tests.

15. Designing for Error Handling and Resilience

Reliable applications distinguish between expected failures and programming defects. Network failures, timeouts, invalid user input, and missing local records should be handled at appropriate boundaries rather than scattered across UI components.

  • Represent user-visible outcomes with explicit UI states.
  • Keep detailed technical errors in appropriate logs, not in user-facing messages.
  • Do not silently swallow exceptions that should be investigated.
  • Preserve coroutine cancellation by rethrowing CancellationException when caught.
  • Use retry behavior only when it makes sense for the operation.
  • Keep validation logic independently testable.
  • Provide meaningful empty, loading, success, and error states.

16. Maintainable Naming, Functions, and Classes

Naming

Use names that express intent. For example, loadProducts() is more informative than doWork(), and ProductRepository communicates more clearly than Helper2.

Small, Focused Functions

A function should perform one coherent task. If a function validates input, performs a network request, writes to a database, and updates several UI elements, consider splitting those responsibilities into focused functions or classes.

Prefer Immutable Data

Use val by default and expose immutable interfaces where possible. For example, expose a StateFlow rather than a mutable MutableStateFlow to consumers. Keep the mutable state private to the component responsible for updating it.

Document Important Decisions

Comments should explain why a non-obvious decision exists, not simply restate what a line of code does. Document important architectural boundaries, constraints, and unusual behavior.

17. Avoiding Common Architecture Mistakes

Mistake Why It Is a Problem Better Approach
Putting everything in an Activity Creates a large, tightly coupled class. Move state and UI logic to ViewModels and data access to repositories.
Calling APIs directly from UI components Makes network behavior difficult to reuse and test. Use a repository and an appropriate API client.
Creating dependencies inside business classes Prevents easy replacement in tests. Use constructor injection and interfaces.
Duplicating business rules Different screens can produce inconsistent results. Centralize shared rules in a suitable domain or shared component.
Overengineering a small app Adds unnecessary classes and maintenance overhead. Introduce abstractions when they improve clarity, reuse, or testing.
Testing only the happy path Leaves failures and boundary conditions unverified. Test success, empty results, invalid input, and expected failures.
Using real services in every unit test Tests become slow, flaky, and dependent on external systems. Use fakes for isolated tests and reserve integration tests for real interactions.
Exposing mutable state everywhere Multiple components can change state unpredictably. Keep state ownership clear and expose read-only state.

18. Refactoring an Existing Android Application

Refactoring improves internal code structure without intentionally changing externally observable behavior. A safe refactoring process should proceed in small steps.

  1. Identify a problem area: Find a large Activity, duplicated calculation, or class that mixes UI and data access.
  2. Record existing behavior: Add tests around important current behavior before changing the implementation.
  3. Extract business rules: Move independent calculations and validation into focused Kotlin functions or classes.
  4. Introduce interfaces: Define clear contracts for repositories or other external dependencies where useful.
  5. Inject dependencies: Supply required implementations through constructors or a DI container.
  6. Move state management: Use a ViewModel or another appropriate state holder for UI-related state.
  7. Add tests: Verify the new components independently and add integration tests for important interactions.
  8. Run the regression suite: Confirm that the refactoring has not changed expected behavior.
  9. Repeat gradually: Refactor one responsibility at a time rather than rewriting the entire application at once.

19. Practical Project: Maintainable Product Catalog App

Build a small product catalog application that retrieves products from a data source and displays them in a searchable list. Structure the project so that business logic and data access can be tested without requiring a live backend for every test.

Suggested Components

  • ProductScreen: Displays products, loading progress, and error messages.
  • ProductViewModel: Manages UI state and responds to user actions.
  • SearchProducts: Optionally encapsulates reusable search rules.
  • ProductRepository: Defines the data access contract.
  • DefaultProductRepository: Coordinates the API or local data source.
  • FakeProductRepository: Supplies predictable data during tests.
  • ProductViewModelTest: Verifies loading, success, empty, and error behavior.

Implementation Steps

  1. Create the Android project using Kotlin and the chosen UI toolkit.
  2. Define the Product model and repository interface.
  3. Implement a fake repository for development and unit tests.
  4. Create a ViewModel that exposes immutable UI state.
  5. Build a screen that renders each UI state clearly.
  6. Add search or filtering logic as an independently testable operation.
  7. Write unit tests for the search rules and ViewModel state transitions.
  8. Add an integration test for the real repository when a suitable test data source is available.
  9. Verify the app handles empty data and failures without crashing.

Expected Outcome

The final application should have understandable boundaries, replaceable dependencies, clear UI state, and automated tests for core behavior. A developer should be able to replace the data source or modify search rules without rewriting the screen.

20. Testing Checklist

  • Does each class have a clear and focused responsibility?
  • Can business logic be tested without launching an Activity?
  • Are network and database dependencies accessed through appropriate abstractions?
  • Can dependencies be replaced with fakes during unit tests?
  • Are loading, success, empty, and failure states handled?
  • Are coroutine tests deterministic and cancellation handled correctly?
  • Are important boundary conditions and invalid inputs covered?
  • Are critical navigation and user workflows covered by UI tests?
  • Are mutable state and implementation details hidden from consumers?
  • Does the architecture remain simple enough for the project's actual needs?

21. Interview Questions and Answers

Q1. What makes an Android application maintainable?

Answer: Clear responsibilities, modular code, descriptive naming, low coupling, limited duplication, consistent conventions, and automated tests all improve maintainability.

Q2. What is testability?

Answer: Testability is how easily a component's behavior can be verified in isolation. Clear interfaces, injected dependencies, and limited framework coupling improve testability.

Q3. Why should business logic be kept out of Activities?

Answer: Activities are lifecycle-managed UI entry points. Placing business logic and data access directly inside them creates coupling and makes independent testing more difficult.

Q4. What is the purpose of a repository?

Answer: A repository provides a consistent interface for accessing application data and coordinates data sources such as APIs, databases, and caches.

Q5. Is a domain layer mandatory in every Android app?

Answer: No. It is optional and is most useful when business logic is complex, reused across features, or benefits from a dedicated abstraction.

Q6. What is dependency injection?

Answer: Dependency injection supplies a class with the objects it needs from outside the class, making dependencies explicit and easier to replace during tests.

Q7. What is the difference between a fake and a mock?

Answer: A fake is a simplified working implementation, such as an in-memory repository. A mock is generally used to verify interactions or configured expectations. Fakes are often easier to use for straightforward repository tests.

Q8. What is separation of concerns?

Answer: It is the practice of dividing an application into components with distinct responsibilities, such as UI rendering, business rules, and data access.

Q9. How does a ViewModel help testing?

Answer: A ViewModel separates UI-related state and logic from Activity or Fragment code and can expose observable state that tests can assert against.

Q10. What is the single source of truth principle?

Answer: It means that a clearly defined component owns updates to a particular piece of data, reducing conflicting copies and improving consistency.

Q11. Which tests should an Android project contain?

Answer: A balanced test suite commonly includes unit tests for business logic, ViewModels and repositories, integration tests for component interactions, and UI tests for important navigation and user workflows.

Q12. How can an existing Android app be made more testable?

Answer: Extract business logic from framework components, define interfaces for external dependencies, inject dependencies, and add focused unit tests before refactoring more complex areas.

22. Quick Revision

  • Maintainability: Ease of understanding and changing code safely.
  • Testability: Ease of verifying behavior independently.
  • Separation of concerns: Give each component a clear responsibility.
  • UI layer: Displays data and handles user interaction.
  • Domain layer: Optional home for reusable or complex business rules.
  • Data layer: Coordinates repositories and data sources.
  • ViewModel: Manages UI-related state and logic.
  • Repository: Abstracts data access from consumers.
  • Dependency injection: Supplies dependencies instead of creating them internally.
  • UDF: Sends actions toward the state holder and state back toward the UI.
  • Unit testing: Verifies a component in isolation.
  • Fakes: Provide controlled test implementations of dependencies.

23. Conclusion

Structuring maintainable and testable Android applications is an essential skill for building reliable software that can evolve over time. By separating UI responsibilities from business rules and data access, using repositories and ViewModels appropriately, injecting dependencies, and writing automated tests, developers can reduce coupling and make future changes safer.

The goal is not to create the largest possible architecture. The goal is to choose a structure that makes responsibilities clear, dependencies replaceable, and important behavior easy to verify. Start with simple boundaries, introduce additional layers when they provide value, and use tests to protect the application's core behavior.

24. Additional Learning Resources

Previous Next
whatsapp