Skip to content
worma .dev
All writing

Writing

Clean Architecture in Practice: Lessons from Real Projects

·6 min read·#Architecture #Clean Code #Android #Kotlin #Software Design

Everyone has seen the concentric circles diagram. Entities in the center, use cases around them, adapters on the outside, frameworks at the edge. You nod along, it makes sense in theory, and then you open your IDE and stare at an empty project wondering where to actually put things.

I have been through that cycle multiple times — mostly in Android apps with Kotlin, but also in backend services. What I have learned is that Clean Architecture is less about the diagram and more about one principle: dependencies point inward. Everything else is negotiable.

Entities: Pure Business Rules

Entities are the innermost layer. They represent your core business concepts and contain rules that would exist even if you had no software at all.

The key constraint: entities import nothing from the outside world. No framework annotations, no database types, no HTTP concepts.

data class Invoice(
    val id: String,
    val lineItems: List<LineItem>,
    val issuedAt: Instant,
) {
    val total: BigDecimal
        get() = lineItems.fold(BigDecimal.ZERO) { acc, item ->
            acc + item.unitPrice * item.quantity.toBigDecimal()
        }

    fun isOverdue(now: Instant): Boolean =
        now > issuedAt.plus(30, ChronoUnit.DAYS) && total > BigDecimal.ZERO
}

Notice there is no @Entity, no @Serializable, no Room annotation. This is a plain data class with plain logic. You can test it with zero setup — no database, no mocking framework, no dependency injection container. Just call the function and check the result.

@Test
fun `invoice with line items calculates total correctly`() {
    val invoice = Invoice(
        id = "inv-1",
        lineItems = listOf(
            LineItem(unitPrice = 10.toBigDecimal(), quantity = 3),
            LineItem(unitPrice = 25.toBigDecimal(), quantity = 1),
        ),
        issuedAt = Instant.now(),
    )

    assertEquals(55.toBigDecimal(), invoice.total)
}

No Robolectric, no @RunWith(AndroidJUnit4::class), no instrumented test. Just a plain JUnit test that runs in milliseconds.

Use Cases: One Class Per User Intention

A use case orchestrates entities and external services to fulfill a single user intention. “Create an invoice.” “Mark an invoice as paid.” “Send a payment reminder.” Each gets its own class.

class SendPaymentReminder(
    private val invoices: InvoiceRepository,
    private val notifications: NotificationService,
    private val clock: Clock,
) {
    suspend fun execute(invoiceId: String) {
        val invoice = invoices.findById(invoiceId)
            ?: throw InvoiceNotFound(invoiceId)

        if (!invoice.isOverdue(clock.instant())) return

        notifications.send(
            to = invoice.customerId,
            message = "Invoice ${invoice.id} is overdue. Total: ${invoice.total}",
        )
    }
}

Two things to notice. First, InvoiceRepository and NotificationService are interfaces — the use case depends on abstractions, not on Room or Firebase or Retrofit. Second, the use case does not know how it was triggered. It could be a ViewModel action, a WorkManager job, or a test. It does not care.

In Android projects I wire these up with Hilt. The use case constructor takes interfaces; Hilt provides the implementations. The use case itself has no @Inject annotation on its body — just on its constructor.

class SendPaymentReminder @Inject constructor(
    private val invoices: InvoiceRepository,
    private val notifications: NotificationService,
    private val clock: Clock,
)

The Dependency Rule: Interfaces as Boundaries

This is the part that actually matters. The inner layers define interfaces. The outer layers implement them. Dependencies always point inward.

// Defined in the domain layer — knows nothing about databases
interface InvoiceRepository {
    suspend fun findById(id: String): Invoice?
    suspend fun findOverdue(asOf: Instant): List<Invoice>
    suspend fun save(invoice: Invoice)
}
// Implemented in the data layer — knows about Room
class RoomInvoiceRepository(
    private val dao: InvoiceDao,
) : InvoiceRepository {

    override suspend fun findById(id: String): Invoice? {
        val entity = dao.findById(id) ?: return null
        return entity.toDomain()
    }

    override suspend fun findOverdue(asOf: Instant): List<Invoice> =
        dao.findOverdue(asOf.toEpochMilli()).map { it.toDomain() }

    override suspend fun save(invoice: Invoice) {
        dao.upsert(invoice.toEntity())
    }
}

The domain layer never imports Room. The Room adapter imports the domain interface and implements it. If you swap Room for SQLDelight next year, the domain layer does not change. You write a new adapter, wire it up in your Hilt module, and your use cases keep working.

This is the port/adapter pattern. The interface is the port. The implementation is the adapter. The name does not matter — what matters is that your business logic never depends on infrastructure details.

The toDomain() and toEntity() mappers at the boundary are important. They are the translation layer between your clean domain types and the framework-specific types that Room or Retrofit need. They are boring code, and that is fine.

When to Break the Rules

Clean Architecture is a guideline, not a religion. Here is when I break the rules intentionally:

Small projects and prototypes. If the entire app is three screens and a database, creating separate domain and data layers is overhead that slows you down. Put your Room entities in the same package as your business logic. You can always extract later if the project grows. The cost of premature abstraction is real.

Framework types in entities. Parcelable is sometimes unavoidable if you need to pass entities between activities or fragments. I add the @Parcelize annotation and move on. Purity is not worth fighting the platform.

Direct DAO access in simple CRUD. If a use case is literally “fetch this row and return it,” wrapping it in a repository interface adds a layer of indirection with zero benefit. I use repositories when there is actual logic to encapsulate — queries with business rules, caching decisions, or combining network and local data sources.

The question I ask myself: does this abstraction make testing easier or the code easier to change? If the answer is no, I skip it.

Common Mistakes

Over-abstracting everything. I have seen codebases with an interface for every class, even when there is exactly one implementation and no foreseeable second one. An interface that is never mocked and never swapped is just noise. Not every ViewModel needs a UseCase that delegates to a Repository that delegates to a DataSource.

Leaking framework types across boundaries. Your use case returns a Room @Entity directly to the ViewModel. Now your presentation layer is coupled to your database schema. Map to a plain domain type at the boundary. Yes, it is extra code. It pays for itself the first time you change your database schema without touching your UI.

Naming without thinking. Packages called domain, data, and presentation do not make your architecture clean. I have seen “clean” Android projects where the domain layer imported Retrofit response types. The package names were right; the dependency direction was wrong.

Putting logic in the wrong layer. Validation that depends only on the data belongs in the entity. Validation that requires a network call (like “is this username already taken?”) belongs in the use case. Validation that depends on UI state (like “has the user touched this field?”) belongs in the ViewModel. Mixing these up creates tangled dependencies.

What Clean Architecture Actually Gives You

When applied with judgment, the benefits are concrete:

Testability. Your business logic is plain classes with injected dependencies. Unit tests are fast, focused, and require no Android framework. I can test an invoice calculation in 5 milliseconds without Robolectric.

Replaceability. I migrated an Android app from Room to SQLDelight. Because the repository interface was the boundary, I replaced one adapter without touching a single use case. The migration was boring, which is exactly what you want.

Readability. When use cases are named after user intentions (SendPaymentReminder, CreateInvoice, SyncOfflineChanges), a new developer can understand what the system does by reading the use case package. They do not have to trace through Fragment → ViewModel → Repository → DAO to figure out the flow.

Parallel development. When boundaries are clear, one developer can build the use case against an interface while another implements the Room adapter. In team settings, this eliminates blocking dependencies between developers working on the same feature.

The Takeaway

Clean Architecture is not about having the right package structure. It is not about having four layers instead of three. It is about one thing: making your business logic independent of the tools you use to deliver it.

Dependencies point inward. Inner layers define interfaces. Outer layers implement them. Everything else — the number of layers, the package names, whether you use classes or functions — is a decision you make based on your project’s size and needs.

The concentric circles diagram is a starting point, not a destination. Use it as a compass for dependency direction, not as a blueprint for package structure. The best “clean” codebase I have worked on had three packages and no interfaces for simple CRUD operations. The worst had perfect package structure and framework imports in every layer.

Think about dependencies, not diagrams.

Share X / Twitter LinkedIn
Related reading