DEV Community

David Njoroge
David Njoroge

Posted on

UNDERSTANDING CLEAN ARCHITECTURE IN SOFTWARE DEVELOPMENT

A developer walks into a bar and orders a beer. The bartender says, "We only serve drinks via an Interface Adapter." The developer replies, "Fine, as long as the beer doesn't know it’s in a glass."

Stop letting business logic enter a toxic, codependent relationship with a UI or a Database. These are "details" that should be treated as plugins, not as the foundation of the system. If the folder structure communicates "Android Framework" instead of "Healthcare System," the architecture is failing. This exhaustive guide is a complete roadmap for escaping the "Big Ball of Mud." It covers everything from the historical convergence of Hexagonal and Onion patterns to a concrete Kotlin implementation featuring self-validating Value Objects that kill bugs before they reach the core. It is a long, high-density read because professional engineering is not a 280-character thread. For those ready to build software that survives the next framework migration, the full masterclass is available here.

Prepare to dismantle the habits of "framework-first" development and rebuild your entire engineering philosophy from the domain outward.

Origins and Evolution of Clean Architecture

The concept of Clean Architecture was formalized by Robert C. Martin ("Uncle Bob") in 2012. It was not a discovery of new principles, but a synthesis of several existing architectural patterns designed to address the increasing entanglement of business logic with framework-specific code.

1. The Precursor Patterns

Clean Architecture integrates ideas from four primary architectural movements:

  • Hexagonal Architecture (Ports and Adapters): Developed by Alistair Cockburn around 2005. It introduced the idea that an application should be equally driven by users, programs, automated tests, or batch scripts, and be developed and tested in isolation from its eventual run-time devices and databases.

hexagonal architecture
Above image is extracted from Hexagonal Architecture and Clean Architecture (with examples) by Dyarlen Iber

  • Onion Architecture: Proposed by Jeffrey Palermo in 2008. This pattern placed the Domain Model at the center and established the rule that all coupling is toward the center. It emphasized that the application core should not depend on the database or any infrastructure concerns.

onion architecture
Above image is extracted from Onion Architecture by Ritesh Kapoor

  • Screaming Architecture: A concept emphasizing that the folder structure and organization of a project should communicate the intent of the system (e.g., "Healthcare System") rather than the frameworks used (e.g., "Ruby on Rails" or "Spring Boot").
  • DCI (Data, Context, and Interaction) and BCE (Boundary-Control-Entity): These patterns contributed to the definition of how objects interact within use cases and how boundaries between the system and the external world are managed.

2. The Synthesis (2012)

Robert C. Martin unified these concepts into a single actionable framework called "Clean Architecture." The goal was to provide a standardized approach to building systems that are:

  • Independent of Frameworks: The architecture does not rely on the existence of some library of feature-laden software. This allows you to use frameworks as tools, rather than cramming your system into their limited constraints.
  • Testable: The business rules can be tested without the UI, Database, Web Server, or any other external element.
  • Independent of UI: The UI can change easily, without changing the rest of the system. A Web UI could be replaced with a console UI, for example, without changing the business rules.
  • Independent of Database: You can swap out SQL Server or Oracle for MongoDB, BigTable, CouchDB, or something else. Your business rules are not bound to the database.

3. The Shift Toward Domain-Driven Design (DDD)

The evolution of Clean Architecture is closely tied to the rise of Domain-Driven Design (DDD). It adopted the DDD emphasis on the "Domain" as the most important part of the software. This led to the structural requirement that the innermost circle—the Entities—contains the most general and high-level rules, while the outer circles contain the mechanisms (UI, DB, etc.). This ensures that the most stable part of the application (the business logic) does not depend on the most volatile parts (the technology stack).


The Core Reasoning

The fundamental objective of Clean Architecture is the isolation of business logic from technical implementation details. This separation ensures that the software remains adaptable, maintainable, and testable throughout its lifecycle.

1. Separation of Concerns and Framework Independence

Software systems often become rigid when business rules are tightly coupled to specific frameworks (e.g., Spring, Express, .NET) or libraries. Clean Architecture treats these as "details." By decoupling the core logic, the organization can upgrade or replace frameworks without rewriting the underlying business rules. This protects the enterprise's intellectual property from the volatility of the technology market.

2. Protection of the Domain (Domain Integrity)

The most critical reasoning behind this architecture is the preservation of the "Domain." The domain contains the rules that define how the business functions.

To ensure the domain remains valid and consistent, Clean Architecture utilizes Value Objects. Unlike Entities, which have a unique identity, Value Objects are defined by their attributes. Their core reasoning includes:

  • Self-Validation: A Value Object is responsible for its own validity. It cannot be instantiated in an invalid state. For example, an Email value object will validate the format upon creation.
  • Immediate Error Handling: If the input data fails business constraints, the Value Object throws a domain-specific error (e.g., InvalidEmailException). This prevents "garbage data" from propagating into the deeper layers of the application.
  • Immutability: Once created, a Value Object cannot be changed. Any modification results in a new instance. This eliminates side effects and makes the system's behavior predictable.

3. Testability Without Side Effects

When business logic is isolated, unit testing does not require a database connection, a web server, or any external infrastructure. Tests run faster and are more reliable because they focus strictly on logic. By using Value Objects that validate themselves, the testing suite can verify that the system correctly rejects invalid data at the boundary of the domain layer, ensuring the "Golden Rule" of the domain is never violated.

4. Dependency Inversion

The reasoning for the Dependency Rule (dependencies point only inward) is to ensure that the high-level policy (Business Rules) does not depend on low-level detail (Data Access, UI).

  • Stable Abstractions: The inner layers define interfaces (Ports).
  • Volatile Implementations: The outer layers implement these interfaces (Adapters). This inversion allows developers to defer decisions about databases or external APIs until they are absolutely necessary, or change them later with minimal impact on the core code.

5. Long-term Velocity

While Clean Architecture requires more boilerplate and initial setup (due to the creation of DTOs, Mappers, and Value Objects), the reasoning is to prevent "Software Rot." By maintaining strict boundaries, the cost of adding new features or changing existing ones remains relatively constant over time, rather than increasing exponentially as the codebase grows.


Architectural Layers

The architecture is organized into four concentric layers, each representing a different level of abstraction and isolation.

Clean architecture by uncle bob

1. Entities (The Domain Layer)

This is the innermost circle, representing the core business rules of the enterprise. It contains the most general and high-level rules that are least likely to change when external factors shift.

  • Entities: Objects that have a unique identity and a lifecycle. They encapsulate the fundamental business logic of the system.
  • Value Objects: These represent descriptive aspects of the domain with no conceptual identity. They are defined by their attributes and are immutable.
    • Validation and Error Handling: Value Objects are responsible for enforcing domain invariants. They validate their own state during instantiation.
    • Logic Enforcement: If a Password Value Object requires a minimum of eight characters, the constructor must validate this constraint. If the input is invalid, it must immediately throw a domain-specific exception (e.g., WeakPasswordException). This ensures that no invalid data can ever penetrate the domain layer.
    • Independence: This layer must have zero dependencies on any external library, framework, or database driver.
Comparison: Entities vs. Value Objects
Feature Entities Value Objects
Identity Defined by a unique identifier (ID) that persists even if other attributes change. No unique identity; defined entirely by the combination of their attributes.
Equality Two entities are equal if their IDs match, regardless of differences in other properties. Two value objects are equal if all their attributes are identical (Structural Equality).
Mutability Mutable. Attributes can change over the entity's lifecycle while the identity remains the same. Immutable. Once created, they cannot be changed. Any "change" results in a new instance.
Lifecycle Has a continuous history and lifecycle (e.g., a User created, updated, and deactivated). Transient and replaceable. They describe a characteristic rather than an individual.
Validation Validation often concerns state transitions or relationships between multiple properties. Self-validating at the point of creation. They cannot exist in an invalid state.
Implementation Usually represented as a class or data class with a specific ID field. In Kotlin, often implemented as value class or data class without an ID.
Example User(id="123", name="John") — John remains User 123 even if he changes his name. Email("john@example.com") — If the email changes, it is a completely different value.
Persistence Mapped to their own database tables with a primary key. Often persisted as columns within the owner's table (Embedded) or as a flat string.
Key Distinction in Clean Architecture
  • Entities represent the "Who" or "What" in the system (e.g., a specific Order or a specific Account). They orchestrate complex business logic that spans multiple state changes.
  • Value Objects represent the "How much," "Where," or "What kind" (e.g., a Price, an Address, or a Color). They serve as the "gatekeepers" of the domain by ensuring that only valid data can be used to construct or update Entities. By throwing errors during instantiation (e.g., InvalidEmailException), they prevent the Domain layer from ever handling corrupted or illogical information.

2. Use Cases (The Application Layer)

This layer contains application-specific business rules. it orchestrates the flow of data to and from the entities and directs those entities to use their enterprise-wide business rules to achieve the goals of the use case.

  • Functionality: It implements all the "features" of the system (e.g., ProcessOrder, RegisterUser, GenerateReport).
  • Decoupling: Use cases do not know how data is stored or how it is presented to the user. They interact with the outer layers through interfaces (Input and Output Ports).
  • Boundary Crossing: This layer defines the contracts (interfaces) that the infrastructure layer must implement.

3. Interface Adapters

This layer is a set of adapters that convert data from the format most convenient for the use cases and entities to the format most convenient for some external agency like the Database or the Web.

  • Controllers: Convert incoming requests (e.g., HTTP requests) into simple data structures that the use cases can understand.
  • Presenters: Convert the results from a use case back into a format suitable for the UI or API response.
  • Gateways: Interfaces used by the application layer to communicate with external systems (like a database or an external mail service).
  • Mappers: This layer contains the logic to map Domain Entities/Value Objects into Data Transfer Objects (DTOs) or Persistence Models, and vice-versa.

4. Frameworks and Drivers (The Infrastructure Layer)

The outermost layer is composed of frameworks and tools such as the Database, the Web Framework, the GUI, and the configuration.

  • External Mechanisms: This is where the actual implementation of the database (e.g., SQL scripts, MongoDB clients) and the web server (e.g., Express, Spring Boot controllers) resides.
  • Volatility: This layer is the most volatile. Because it is kept separate from the inner layers, replacing a database or switching from a REST API to a message queue involves changing code only in this layer.
  • Dependency: This layer depends on the inner layers by implementing the interfaces defined in the Use Case or Domain layers. It is the "plug-in" area of the architecture.

Data Flow and Dependency Inversion

In Clean Architecture, data flow and dependency direction are distinct concepts. While data flows in a circular or bidirectional path (from the user to the database and back), source code dependencies must always point inward toward the Domain.


This image is extracted from How data flows in Clean Architecture by Jelena Cupać

1. The Flow of Control vs. Dependency Direction

  • Flow of Control: Starts in the Presentation layer (User clicks a button), moves through the Application layer (Use Case), into the Domain (Entities), then out to the Infrastructure (Database), and finally back to the UI.
  • Dependency Direction: Every layer depends only on the layer immediately inside it. The Infrastructure layer depends on the Domain, but the Domain knows nothing about the Infrastructure.

2. Dependency Inversion Principle (DIP)

To allow the Application layer to communicate with the Database without depending on it, we use Dependency Inversion.

  • The Problem: A Use Case needs to save a User entity. If the Use Case calls SqlDatabase.save(), the Application layer now depends on a specific database framework.
  • The Solution: The Application layer defines an interface (an Output Port), such as UserRepository. The Infrastructure layer then implements this interface. At runtime, the implementation is "plugged in," but the source code dependency remains pointing inward to the interface.

3. Input and Output Ports

Ports are the entry and exit points of the application core (Domain + Application layers).

  • Input Ports (Driving Adapters): Interfaces that allow external agents (UI, CLI, Tests) to tell the application what to do.
    • Example: RegisterUserUseCase interface. The ViewModel calls this port to trigger business logic.
  • Output Ports (Driven Adapters): Interfaces used by the application core to get data from or send data to the outside world.
    • Example: UserRepository or EmailService interfaces. The Use Case calls these ports to persist data or send notifications.

4. Data Transfer Objects (DTOs) vs. Domain Models

Crossing architectural boundaries requires translating data between different formats to maintain isolation.

Feature Data Transfer Object (DTO) Domain Model (Entity/Value Object)
Location Presentation or Infrastructure Domain Layer
Purpose Data transportation (API/DB) Business Logic and Rules
Structure Flat, simple data types (Strings, Ints) Rich objects (Value Objects)
Validation Minimal (Format/Null checks) Strict (Business Invariants)
Stability Volatile (Changes with API/DB schema) Stable (Changes with Business Rules)

5. Crossing the Boundary: The Mapping Process

When data moves through the layers, it must be mapped to ensure the Domain remains "clean."

  1. Request Entry: The Presentation layer receives a LoginRequestDto. It extracts the raw strings.
  2. Domain Entry: The Application layer takes those strings and passes them into the Domain. At this point, Value Objects are instantiated.
    • val email = Email(dto.email)
    • val password = Password(dto.password)
    • Validation: If the DTO contains an invalid email, the Email Value Object throws an error immediately. The Use Case never executes with invalid data.
  3. Persistence: The Use Case passes the validated User Entity to the UserRepository (Output Port).
  4. Data Exit: The Infrastructure layer receives the Entity and maps it to a UserDbModel (Persistence DTO) for the database.

This strict mapping prevents "Anemic Domain Models" (objects with only getters and setters) and ensures that the core business logic is never compromised by external data requirements.


File Structure (Layer-Wise)

This structure organizes the project strictly by architectural layers. It separates technical concerns across the entire application, which is suitable for smaller to mid-sized projects where a feature-based split is not yet required.

src/
├── domain/                         # Enterprise-wide business logic and rules
│   ├── entities/                   # Core objects with identity (e.g., User.ts, Order.ts)
│   ├── value-objects/              # Immutable objects with self-validation logic
│   │   ├── Email.ts                # Validates format; throws InvalidEmailError
│   │   ├── Password.ts             # Validates strength; throws WeakPasswordError
│   │   └── Money.ts                # Validates non-negative; throws InvalidAmountError
│   ├── repositories/               # Interfaces/Contracts for data persistence
│   ├── exceptions/                 # Domain-specific error classes
│   └── services/                   # Domain services for logic involving multiple entities
│
├── application/                    # Application-specific business rules (Use Cases)
│   ├── use-cases/                  # Orchestrators (e.g., CreateUser.ts, GetOrder.ts)
│   ├── ports/                      # Interfaces for external services (e.g., IMailer.ts)
│   └── dtos/                       # Data Transfer Objects for Use Case input/output
│
├── infrastructure/                 # External implementations and technical details
│   ├── persistence/                # Database-specific logic
│   │   ├── models/                 # Database schemas or ORM entities
│   │   ├── repositories/           # Concrete implementations of Domain repositories
│   │   └── mappers/                # Maps Persistence Models <-> Domain Entities
│   ├── external-services/          # Concrete implementations of Application ports
│   │   ├── mailer/                 # e.g., SendGrid or SMTP implementation
│   │   └── storage/                # e.g., AWS S3 or LocalStorage implementation
│   └── config/                     # Environment and framework-specific configurations
│
├── presentation/                   # Entry points and delivery mechanisms
│   ├── http/                       # REST/GraphQL API implementation
│   │   ├── controllers/            # Handles requests and invokes Use Cases
│   │   ├── middlewares/            # Auth, validation, and error handling
│   │   └── routes/                 # Endpoint definitions
│   ├── cli/                        # Terminal/Command-line interface logic
│   └── view-models/                # Formatting logic for the final response/UI
│
└── main/                           # Composition Root
    ├── di/                         # Dependency Injection wiring and containers
    ├── factories/                  # Logic for assembling complex object graphs
    └── index.ts                    # Application entry point
Enter fullscreen mode Exit fullscreen mode

Layer-Wise Component Details

Domain Layer: Value Objects and Validation

In this layer-based approach, all Value Objects reside in domain/value-objects/.

  • Validation Logic: Every Value Object contains a private constructor and a static create method (or logic within the constructor) that performs validation.
  • Error Handling: If an input violates a business rule (e.g., a ZipCode that is not 5 digits), the Value Object throws a specific exception from domain/exceptions/. This ensures that an invalid object can never exist within the application or infrastructure layers.

Application Layer: Use Case Orchestration

The application/use-cases/ directory contains the logic that bridges the UI and the Domain.

  • Process: It receives a DTO from the presentation layer, uses the domain/value-objects/ to validate the data, interacts with domain/repositories/ (interfaces), and returns a result.
  • Isolation: This layer knows "what" needs to happen but has no knowledge of "how" the data is stored or "how" the response is rendered.

Infrastructure Layer: The Implementation Detail

The infrastructure/ directory contains the "low-level" code.

  • Mappers: Crucial in this structure to prevent database-specific logic from leaking. infrastructure/persistence/mappers/ convert raw database rows into the rich Domain Entities and Value Objects defined in the center.
  • Adapters: If the application needs to send an email, the concrete implementation (e.g., NodemailerAdapter) stays here, satisfying the interface defined in application/ports/.

Presentation Layer: Data Transformation

This layer is responsible for the "delivery" of the application.

  • Controllers: In presentation/http/controllers/, the code handles the web-specific details like HTTP status codes and headers. It maps the incoming request body into an Application DTO.
  • Error Mapping: This layer is also responsible for catching the domain-specific exceptions (thrown by Value Objects) and converting them into user-friendly error messages or appropriate HTTP status codes (e.g., 400 Bad Request).

Native Android (Kotlin) Clean Architecture File Structure

In standard Android development, Clean Architecture is typically organized into three primary layers: Domain (pure Kotlin), Data (Infrastructure), and Presentation (Android UI). These can be implemented as separate Gradle modules or as packages within a single app module.

Below is the package structure for a single-module Android application utilizing modern components like Jetpack Compose, ViewModels, Room, and Retrofit.

app/src/main/java/com/example/app/
├── domain/                             # Pure Kotlin/Java (No Android dependencies)
│   ├── model/                          # Entities and Value Objects
│   │   ├── User.kt                     # Entity with identity
│   │   └── valueobject/
│   │       ├── Email.kt                # Kotlin class/value class with validation
│   │       ├── Password.kt             # Validates rules and throws Domain Exceptions
│   │       └── Money.kt
│   ├── repository/                     # Interface definitions for data operations
│   │   └── UserRepository.kt
│   ├── usecase/                        # Single-purpose business rules
│   │   ├── CreateUserUseCase.kt
│   │   └── GetUserUseCase.kt
│   └── exception/                      # Custom Domain Exceptions
│       └── DomainExceptions.kt         # e.g., InvalidEmailException, WeakPasswordException
│
├── data/                               # Data management and Infrastructure
│   ├── repository/                     # Implementations of Domain repositories
│   │   └── UserRepositoryImpl.kt
│   ├── mapper/                         # Maps Data Entities <-> Domain Models
│   │   └── UserMapper.kt
│   ├── local/                          # Room Database, SQLite, DataStore
│   │   ├── database/ AppDatabase.kt
│   │   ├── dao/ UserDao.kt
│   │   └── entity/ UserEntity.kt       # Room Database Entity
│   └── remote/                         # Retrofit, Ktor, API Clients
│       ├── api/ UserApiService.kt
│       └── dto/ UserResponseDto.kt     # Network response objects
│
├── presentation/                       # Android UI and ViewModels
│   ├── common/                         # Reusable UI components, themes
│   ├── user/                           # Grouped by screen/flow
│   │   ├── UserViewModel.kt            # Orchestrates Use Cases and manages UI state
│   │   ├── UserUiState.kt              # Data class representing the screen state
│   │   └── UserScreen.kt               # Jetpack Compose UI (or Fragment/Activity)
│   └── mapper/                         # UI Mappers (Domain -> UI State)
│
└── di/                                 # Dependency Injection (Hilt, Koin, or Dagger)
    └── AppModule.kt                    # Provides DB, API, and Repository singletons
Enter fullscreen mode Exit fullscreen mode

Domain Layer: Code Samples (Kotlin/Android)

The Domain Layer is pure Kotlin and contains zero dependencies on Android frameworks, databases, or network libraries.

1. Custom Domain Exceptions

Define specific exceptions to represent business rule violations.

sealed class DomainException(message: String) : Exception(message)

class InvalidEmailException(email: String) : 
    DomainException("The provided email '$email' is not a valid format.")

class WeakPasswordException(reason: String) : 
    DomainException("Password does not meet security requirements: $reason")

class NegativeAmountException : 
    DomainException("Monetary amounts cannot be negative.")
Enter fullscreen mode Exit fullscreen mode

2. Value Objects with Validation

Value Objects encapsulate validation logic. They are immutable and ensure that the system never processes "garbage" data.

@JvmInline
value class Email(val value: String) {
    init {
        val emailRegex = "^[A-Za-z0-9+_.-]+@(.+)\$".toRegex()
        if (!value.matches(emailRegex)) {
            throw InvalidEmailException(value)
        }
    }
}

data class Password(val value: String) {
    init {
        if (value.length < 8) {
            throw WeakPasswordException("Must be at least 8 characters.")
        }
        if (!value.any { it.isDigit() }) {
            throw WeakPasswordException("Must contain at least one digit.")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Entities

Entities have a unique identity (ID) and encapsulate enterprise-wide business rules.

data class User(
    val id: String,         // Unique Identity
    val email: Email,       // Value Object
    val password: Password, // Value Object
    val isActive: Boolean = true
) {
    // Business logic within the entity
    fun deactivate(): User {
        return this.copy(isActive = false)
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Repository Interfaces (Ports)

These interfaces define the contracts for data operations. The implementation resides in the Infrastructure/Data layer.

interface UserRepository {
    suspend fun save(user: User)
    suspend fun findByEmail(email: Email): User?
    suspend fun exists(email: Email): Boolean
}
Enter fullscreen mode Exit fullscreen mode

5. Use Cases (Application-Specific Rules)

Use Cases orchestrate the flow of data. They use the Repository interfaces and Domain Entities to perform a specific task.

class RegisterUserUseCase(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(emailInput: String, passwordInput: String) {
        // 1. Validation happens automatically during Value Object instantiation
        val email = Email(emailInput)
        val password = Password(passwordInput)

        // 2. Business Rule Check
        if (userRepository.exists(email)) {
            throw DomainException("A user with this email already exists.")
        }

        // 3. Entity Creation
        val newUser = User(
            id = java.util.UUID.randomUUID().toString(),
            email = email,
            password = password
        )

        // 4. Persistence via Interface
        userRepository.save(newUser)
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Implementation Principles in this Layer

  • Validation at the Gate: The Email and Password objects cannot be created in an invalid state. If the RegisterUserUseCase receives bad strings, the code crashes (with a caught DomainException) before any business logic is executed.
  • No Frameworks: Notice the absence of Context, Parcelable, JSONObject, or Room annotations (@Entity, @PrimaryKey). This ensures the core logic is 100% unit-testable on a standard JVM without an emulator.
  • Immutability: Data classes and value classes are used to ensure that once a domain object is validated and created, its state cannot be modified unpredictably.

Data Layer: Code Samples (Kotlin/Android with API)

The Data Layer (Infrastructure) implements the interfaces defined in the Domain Layer. It handles the technical details of communicating with external APIs using libraries like Retrofit and mapping external data formats into Domain Entities and Value Objects.

1. Data Transfer Objects (DTOs)

These classes represent the structure of the JSON data returned by the API. They are annotated with serialization metadata and remain separate from Domain Entities.

import com.google.gson.annotations.SerializedName

data class UserResponseDto(
    @SerializedName("id") val id: String,
    @SerializedName("email_address") val email: String,
    @SerializedName("is_active") val isActive: Boolean,
    @SerializedName("created_at") val createdAt: String
)

data class UserRequestDto(
    @SerializedName("email") val email: String,
    @SerializedName("password_hash") val passwordHash: String
)
Enter fullscreen mode Exit fullscreen mode

2. Retrofit API Service

This interface defines the HTTP endpoints. It uses the DTOs for requests and responses.

import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path

interface UserApiService {
    @GET("users/{id}")
    suspend fun getUserById(@Path("id") id: String): UserResponseDto

    @POST("users/register")
    suspend fun registerUser(@Body request: UserRequestDto): UserResponseDto
}
Enter fullscreen mode Exit fullscreen mode

3. Data Mappers

Mappers are responsible for converting DTOs into Domain Entities. During this conversion, strings from the API are passed into Value Objects, which triggers the domain-level validation.

// Extension functions for mapping
fun UserResponseDto.toDomain(): User {
    return User(
        id = this.id,
        // Passing raw API string into the Value Object constructor
        // If the API returns an invalid email, the Value Object throws an exception here
        email = Email(this.email), 
        password = Password("dummy_api_password"), // Passwords typically aren't returned by APIs
        isActive = this.isActive
    )
}

fun User.toRequestDto(): UserRequestDto {
    return UserRequestDto(
        email = this.email.value,
        passwordHash = this.password.value // Simplified for example
    )
}
Enter fullscreen mode Exit fullscreen mode

4. Repository Implementation

This class implements the UserRepository interface from the Domain Layer. It uses the UserApiService to fetch data and the mappers to return domain objects to the application layer.

class UserRepositoryImpl(
    private val apiService: UserApiService
) : UserRepository {

    override suspend fun findByEmail(email: Email): User? {
        return try {
            // Note: In a real scenario, you would have an endpoint to find by email
            val response = apiService.getUserById(email.value)
            response.toDomain()
        } catch (e: Exception) {
            // Log error or handle network-specific exceptions
            null
        }
    }

    override suspend fun save(user: User) {
        val requestDto = user.toRequestDto()
        apiService.registerUser(requestDto)
    }

    override suspend fun exists(email: Email): Boolean {
        return try {
            apiService.getUserById(email.value)
            true
        } catch (e: Exception) {
            false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Implementation Principles in this Layer

  • Decoupling: The UserResponseDto can change its field names (e.g., from email_address to contact_email) without affecting the User entity in the Domain Layer. Only the Mapper needs to be updated.
  • Infrastructure Isolation: Retrofit annotations and JSON logic are confined to this layer. The Domain Layer remains unaware of the networking library being used.
  • Boundary Validation: When response.toDomain() is called, the Email(this.email) constructor is invoked. If the server sends an invalid email format, the system throws a DomainException at the boundary of the Data Layer, preventing invalid data from entering the application core.
  • Dependency Inversion: This layer depends on the UserRepository interface (Domain) to know what methods to implement, but the Domain does not depend on this implementation.

Presentation Layer: Code Samples (Kotlin/Android)

The Presentation Layer is responsible for rendering data to the screen and capturing user interactions. In modern Android development, this involves a ViewModel to manage state and a UI Component (Jetpack Compose or Fragments) to display it.

1. UI State Representation

The UI state is a data class or sealed class that represents exactly what the user sees on the screen at any given moment.

sealed class RegistrationUiState {
    object Idle : RegistrationUiState()
    object Loading : RegistrationUiState()
    object Success : RegistrationUiState()
    data class Error(val message: String) : RegistrationUiState()
}
Enter fullscreen mode Exit fullscreen mode

2. ViewModel Implementation

The ViewModel interacts with the Use Cases. It is responsible for catching DomainException thrown by Value Objects or Use Cases and converting them into human-readable error messages for the UI.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class RegistrationViewModel(
    private val registerUserUseCase: RegisterUserUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<RegistrationUiState>(RegistrationUiState.Idle)
    val uiState: StateFlow<RegistrationUiState> = _uiState

    fun onRegisterClicked(emailInput: String, passwordInput: String) {
        viewModelScope.launch {
            _uiState.value = RegistrationUiState.Loading

            try {
                // The Use Case is called with raw strings.
                // Internally, the Use Case creates Value Objects (Email, Password).
                // If validation fails, a DomainException is thrown immediately.
                registerUserUseCase(emailInput, passwordInput)

                _uiState.value = RegistrationUiState.Success
            } catch (e: DomainException) {
                // Catch specific domain errors and update the UI state
                _uiState.value = RegistrationUiState.Error(e.message ?: "Invalid Input")
            } catch (e: Exception) {
                // Catch unexpected system/network errors
                _uiState.value = RegistrationUiState.Error("An unexpected error occurred")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. UI Component (Jetpack Compose)

The UI observes the uiState from the ViewModel and reacts to changes. It does not contain any business logic or validation logic; it simply passes strings to the ViewModel.

@Composable
fun RegistrationScreen(viewModel: RegistrationViewModel) {
    val state by viewModel.uiState.collectAsState()
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") }
        )

        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("Password") },
            visualTransformation = PasswordVisualTransformation()
        )

        Button(onClick = { viewModel.onRegisterClicked(email, password) }) {
            Text("Register")
        }

        // Display state-specific UI
        when (state) {
            is RegistrationUiState.Loading -> CircularProgressIndicator()
            is RegistrationUiState.Success -> Text("Account Created Successfully!", color = Color.Green)
            is RegistrationUiState.Error -> Text("Error: ${(state as RegistrationUiState.Error).message}", color = Color.Red)
            else -> {}
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Implementation Principles in this Layer

  • Logic-Free Views: The RegistrationScreen does not check if the email is valid. It delegates that responsibility to the Domain layer (via the ViewModel and Use Case). This ensures that validation rules are never duplicated in the UI code.
  • State-Driven UI: The UI is a "dumb" reflection of the RegistrationUiState. This makes the UI highly predictable and easy to test with Compose UI tests.
  • Exception Translation: The ViewModel acts as a translator. It catches technical or domain-specific exceptions (like WeakPasswordException) and transforms them into a state that the UI can render (an error message string).
  • Decoupling from Domain: The UI layer depends on the ViewModel, and the ViewModel depends on the Use Case interface. The UI never interacts directly with the UserRepository or the UserApiService.

Read More from the following blogs

The Clean Code Blog by Robert C. Martin (Uncle Bob)

Clean Architecture by Rudraksh Nanavaty

Understand Clean Architecture in 7 Minutes - Youtube Video

The Clean Architecture EXPLAINED in 9 MINUTES | Clean vs Onion Architecture - Youtube Video

The Clean Architecture I Wish Someone Had Explained to Me by Rafael C

Clean Architecture for mobile: To be, or not to be

CLEAN Architecture in Android: a practical way to think about layers, mappers, dependencies, and modules

Top comments (0)