Skip to content
advanced Phase 6 · Architecture Patterns

Clean Architecture

Structure apps into presentation, domain, and data layers for testability and maintainability.

1h
0 problems
Topic Progress 0%

The Three Layers

Why Layered Architecture?

As an app grows, mixing UI code, business rules, and network calls in the same classes becomes unmanageable. Clean Architecture enforces separation of concerns by dividing the codebase into concentric layers where each layer depends only on the layer inside it.

Presentation Layer

Contains everything UI-related: Activities, Fragments, Composables, ViewModels, and UI state classes. The Presentation layer knows about the Domain layer but knows nothing about where data comes from. A ViewModel calls a Use Case and renders the result.

Domain Layer

The innermost layer. Contains Use Cases (interactors), business models, and repository interfaces. This layer has zero Android framework dependencies — no LiveData, no Context, no Coroutines dispatcher. It is pure Kotlin. This is what makes it testable.

Data Layer

Implements repository interfaces defined in the Domain layer. Contains API services, database DAOs, data mappers, and data models. The Data layer knows about the Domain layer's interfaces but the Domain layer does not know about the Data layer's implementation.

The Dependency Rule

Dependencies point inward only:

Presentation -> Domain <- Data

The Domain layer defines interface UserRepository. The Data layer implements class ApiUserRepository : UserRepository. The Domain never imports from the Data layer. This inversion means you can swap the data source (API to local database) without touching any Domain or Presentation code.

// Domain layer -- no Android imports
class GetUserUseCase(private val userRepository: UserRepository) {
    suspend operator fun invoke(userId: String): User {
        return userRepository.getUser(userId)
    }
}

interface UserRepository {
    suspend fun getUser(id: String): User
    suspend fun updateUser(user: User)
}

// Data layer -- implements the interface
class RetrofitUserRepository(
    private val api: UserApi,
    private val dao: UserDao
) : UserRepository {
    override suspend fun getUser(id: String): User {
        return try {
            val response = api.fetchUser(id)
            dao.insertUser(response.toDomain())
            response.toDomain()
        } catch (e: Exception) {
            dao.getUser(id) ?: throw e
        }
    }

    override suspend fun updateUser(user: User) {
        dao.updateUser(user.toEntity())
        api.updateUser(user.toDto())
    }
}

Use Cases and Business Logic

What is a Use Case?

A Use Case (or Interactor) encapsulates a single business action. It orchestrates between repositories and models to fulfill one specific requirement. A Use Case does not know whether it is called from Android, a CLI, or a unit test.

When to Create a Use Case

Not every operation needs one. Create a Use Case when:

  • The operation involves multiple repository calls or business rules
  • The same logic is used from multiple places
  • The logic needs to be tested independently of the UI

Skip Use Cases for trivial pass-throughs — if the ViewModel just calls repository.getUser(), the Use Case adds no value.

One Use Case, One Responsibility

// Good: Single responsibility
class GetCartItemsUseCase(
    private val cartRepository: CartRepository,
    private val priceCalculator: PriceCalculator
) {
    suspend operator fun invoke(): CartSummary {
        val items = cartRepository.getCartItems()
        val subtotal = priceCalculator.calculateSubtotal(items)
        val tax = priceCalculator.calculateTax(subtotal)
        return CartSummary(items, subtotal, tax, subtotal + tax)
    }
}

// Bad: Multiple responsibilities mixed
class CartUseCase(
    private val cartRepository: CartRepository,
    private val orderRepository: OrderRepository,
    private val userRepository: UserRepository
) {
    suspend fun getCart() = cartRepository.getCartItems()
    suspend fun placeOrder() = orderRepository.createOrder()
    suspend fun getAddresses() = userRepository.getAddresses()
}

The operator fun invoke() pattern lets you call the Use Case like a function: val summary = getCartItemsUseCase().

Testing Use Cases

Since Use Cases depend only on interfaces, testing is straightforward:

class GetCartItemsUseCaseTest {
    private val cartRepository = mockk<CartRepository>()
    private val priceCalculator = mockk<PriceCalculator>()
    private val useCase = GetCartItemsUseCase(cartRepository, priceCalculator)

    @Test
    fun `returns correct cart summary`() = runTest {
        val items = listOf(CartItem("1", "Widget", 2, 9.99))
        coEvery { cartRepository.getCartItems() } returns items
        every { priceCalculator.calculateSubtotal(items) } returns 19.98
        every { priceCalculator.calculateTax(19.98) } returns 1.60

        val summary = useCase()

        assertEquals(19.98, summary.subtotal)
        assertEquals(1.60, summary.tax)
        assertEquals(21.58, summary.total)
    }
}

Testability and Trade-offs

Test Pyramid with Clean Architecture

Clean Architecture maps directly to the test pyramid:

  • Unit tests (many): Use Cases and Domain models — pure Kotlin, fast, no mocks needed
  • Integration tests (some): Data layer — test repository implementations against real or fake APIs
  • UI tests (few): Presentation layer — test ViewModel state transitions and UI rendering

What Makes Testing Easy

Because the Domain layer has no framework dependencies, you can run thousands of Use Case tests in milliseconds. No Robolectric, no device, no emulator.

// Domain model -- no Android imports
data class Order(
    val id: String,
    val items: List<OrderItem>,
    val status: OrderStatus
) {
    fun canBeCancelled(): Boolean = status == OrderStatus.PENDING
    fun totalAmount(): Double = items.sumOf { it.price * it.quantity }
}

// Test the model directly -- no framework needed
@Test
fun `order is cancellable only when pending`() {
    val order = Order("1", emptyList(), OrderStatus.PENDING)
    assertTrue(order.canBeCancelled())

    val shippedOrder = Order("2", emptyList(), OrderStatus.SHIPPED)
    assertFalse(shippedOrder.canBeCancelled())
}

The Trade-off

Clean Architecture adds files and indirection. A simple screen might need a UseCase, a Repository interface, a Repository implementation, a data model, a domain model, and a mapper. For small projects this is overhead. For large teams and long-lived apps, this structure pays for itself within weeks as the codebase grows.

Dependency Injection Wiring

Hilt or Koin connects everything at the composition root:

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindUserRepository(impl: RetrofitUserRepository): UserRepository
}

@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
    @Provides
    fun provideGetUserUseCase(repo: UserRepository) = GetUserUseCase(repo)
}

Quiz

1. In Clean Architecture, which direction do dependencies point?

Question 1 options

2. Which layer contains Use Cases in Clean Architecture?

Question 2 options

3. Why does the Domain layer define repository interfaces instead of using concrete implementations?

Question 3 options

4. When should you NOT create a Use Case for an operation?

Question 4 options

Flashcards

Question

What are the three layers of Clean Architecture on Android?

Answer

Presentation (UI + ViewModel), Domain (Use Cases + business models + repository interfaces), Data (API + Database + repository implementations).

Question

What is the Dependency Rule in Clean Architecture?

Answer

Dependencies point inward only. Presentation depends on Domain. Data implements Domain interfaces. Domain never imports from Presentation or Data.

Question

Why should the Domain layer have zero Android framework dependencies?

Answer

So Use Cases and business models can be tested with plain JVM unit tests — no Robolectric, no emulator, no instrumentation needed.

Question

What is the `operator fun invoke()` pattern used for in Use Cases?

Answer

It lets you call a Use Case instance like a function: `val result = getUserUseCase(userId)`. Clean, readable, and hides the class name.

Revision Notes

Key Takeaways

  • 1. Clean Architecture separates Presentation, Domain, and Data into distinct layers
  • 2. The Dependency Rule ensures Domain has zero outward dependencies
  • 3. Use Cases encapsulate single-responsibility business logic with operator fun invoke()
  • 4. This structure pays off in large apps where testability and maintainability matter most

Interview Tips

  • Draw the three concentric circles and explain why dependencies point inward
  • Explain how swapping a data source (API to cache) requires zero Domain changes
  • Discuss when Use Cases add value vs when they are unnecessary indirection
  • Know the difference between Domain models (business) and Data models (API/DB)

Cheat Sheet

Clean Architecture Cheat Sheet

Layers:

  • Presentation: Activity, Fragment, Composable, ViewModel, UI State
  • Domain: Use Cases, Business Models, Repository Interfaces
  • Data: API Services, DAOs, Repository Implementations, Data Models

Dependency Rule:

Presentation -> Domain <- Data

Domain defines interfaces. Data implements them. Dependencies point inward only.

Use Case Pattern:

class GetUserUseCase(private val repo: UserRepository) {
    suspend operator fun invoke(id: String): User = repo.getUser(id)
}

When to Use Use Cases:

  • Multi-step business logic
  • Logic reused across features
  • Logic that needs independent testing
  • Skip for trivial pass-throughs

Testing:

  • Domain: Pure JVM unit tests (fast, no mocks)
  • Data: Integration tests with real/fake API
  • Presentation: ViewModel state tests