Clean Architecture — basis, lagen Entities, Use Cases en Gateways

Auteur: IT Sectr Gepubliceerd: 2026-02-17 Leestijd: 10 min

Clean Architecture — gelaagde architectuur voorgesteld door Robert Martin (Uncle Bob) in 2012, die de applicatie verdeelt in onafhankelijke lagen: Domain (Entities, Use Cases), Data (Repositories, DataSources) en Presentation (ViewModels, Views). Het belangrijkste principe — Dependency Rule: afhankelijkheden zijn naar binnen gericht, externe lagen zijn afhankelijk van interne, maar niet andersom. Clean Architecture wordt toegepast in mobiele ontwikkeling voor projecten met hoge complexiteit van bedrijfslogica. Meer — in het boek The Clean Architecture.

Belangrijkste

  • Clean Architecture — drie lagen: Domain (bedrijfslogica), Data (gegevens), Presentation (UI) met Dependency Rule
  • Dependency Rule — afhankelijkheden zijn naar binnen gericht, Domain weet niets van Data en Presentation
  • Use Cases (Interactors) — scenario's van bedrijfslogica, elke Use Case — één klasse met één methode
  • Repository Interface — gegevensabstractie in Domain, implementatie — in de Data-laag
  • Testbaarheid — Domain en Use Cases worden getest met eenheidstests zonder Android SDK en iOS UIKit

Clean Architecture — basis van gelaagde architectuur

Clean Architecture — architectuurpatroon geformuleerd door Robert Martin (Uncle Bob) in 2012. Het hoofdidee — verdeling van de applicatie in lagen met een strikte afhankelijkheidsregel: code binnen een laag weet niets van code buiten. Externe lagen (UI, frameworks, databases) — implementatiedetails. Interne lagen (bedrijfslogica, bedrijfsregels) — de essentie van de applicatie.

Clean Architecture lagen in mobiele ontwikkeling: 1) Domain — Entities (bedrijfsobjecten) en Use Cases (gebruiksscenario's); 2) Data — RepositoryImpl (implementatie van repositories), DataSources (netwerk, database, cache); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain — de binnenste laag, zonder afhankelijkheden. Data is afhankelijk van Domain (implementeert de repository-interfaces). Presentation is afhankelijk van Domain (roept Use Cases aan, abonneert zich op het resultaat).

LaagBevatAfhankelijkheden
DomainEntities, Use Cases, Repository InterfacesGeen (pure Kotlin/Swift)
DataRepositoryImpl, DataSources (API, DB, Cache)Domain, Retrofit, Room, Ktor
PresentationViewModels, Views, ComposablesDomain, Jetpack, SwiftUI

Dependency Rule — de enige strikte regel van Clean Architecture. Broncode mag alleen verwijzen naar de laag binnen zichzelf of naar de laag eronder (dichter bij het centrum). Presentation importeert Domain. Domain importeert NIET Data of Presentation. Dit wordt bereikt door inversie van afhankelijkheden (Dependency Inversion Principle): Domain definieert de Repository-interface, Data implementeert deze. Presentation is afhankelijk van de UseCase-abstractie, niet van een concrete repository.

Domain Layer: Entities, Use Cases en Repository Interfaces

Domain — de meest stabiele laag van de applicatie. Entities — bedrijfsobjecten onafhankelijk van frameworks: User, Product, Order. Use Cases — klassen met één methode invoke (of operator fun invoke in Kotlin), die één scenario implementeren: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces — abstracties van gegevenstoegang, gedefinieerd in Domain, geïmplementeerd in Data. Domain bevat geen Android SDK, iOS UIKit, Retrofit, Room — alleen pure Kotlin of Swift.

swift
// Entity — bedrijfsobject (Domain)
struct User: Equatable {
    let id: Int
    let name: String
    let email: String
}

// Repository Interface — gegevensabstractie (Domain)
protocol UserRepository {
    func getUser(id: Int) async throws -> User
    func getUsers() async throws -> [User]
}

// Use Case — één scenario (Domain)
final class GetUserUseCase {
    private let repository: UserRepository

    init(repository: UserRepository) {
        self.repository = repository
    }

    func execute(id: Int) async throws -> User {
        return try await repository.getUser(id: id)
    }
}

Use Case — «klasse met één methode» — geen dogma, maar een praktische aanbeveling. Wanneer Use Case complexer wordt (validatie + loggen + repository-aanroep), worden de methoden gegroepeerd op betekenis: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. Belangrijk — Use Case mag niet weten waar de gegevens vandaan komen (netwerk, database, cache) en wie ze weergeeft (Compose, SwiftUI). Bij IT Sectr wijzen we een Use Case toe voor elke operatie met een bedrijfsregel, validatie of combinatie van gegevens uit twee bronnen.

Zuiverheid van Domain wordt bereikt door DTO-mapping op de grenzen van de lagen. De Data-laag ontvangt JSON-modellen (DTO), mapt ze naar Domain Entity. Presentation ontvangt Domain Entity, mapt naar ViewModel (DisplayItem). Domain Entity bevat nooit Retrofit-, Room- of Codable-annotaties — dit garandeert dat de laag niet hoeft te worden gewijzigd bij het overschakelen van Room naar Realm of bij het vervangen van Retrofit door Ktor.

Data Layer: Repository Implementation en DataSources

Data Layer — implementatie van de in Domain gedefinieerde interfaces. Bevat RepositoryImpl (klassen die UserRepository implementeren) en DataSources (RemoteDataSource — API, LocalDataSource — database, CacheDataSource — SharedPreferences/NSUserDefaults). De Data-laag is afhankelijk van Domain (importeert repository-interfaces en Entities) en van frameworks (Retrofit, Room, Ktor, CoreData). RepositoryImpl verbergt de gegevensbron voor Domain — Use Case weet niet of gegevens uit het netwerk of de cache komen.

kotlin
// DTO — model voor netwerk (Data)
data class UserDto(
    @SerializedName("id") val id: Int,
    @SerializedName("first_name") val firstName: String,
    @SerializedName("last_name") val lastName: String,
    @SerializedName("email") val email: String
)

// RepositoryImpl — implementatie (Data)
class UserRepositoryImpl(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource
) : UserRepository {

    override suspend fun getUser(id: Int): User {
        // Proberen uit cache te halen
        localDataSource.getUser(id)?.let { return it.toDomain() }
        // Indien niet — laden van netwerk
        val dto = remoteDataSource.fetchUser(id)
        val user = dto.toDomain()
        localDataSource.saveUser(user)
        return user
    }

    override suspend fun getUsers(): List<User> {
        return remoteDataSource.fetchAllUsers().map { it.toDomain() }
    }
}

// Mapper — conversie DTO ↔ Domain
fun UserDto.toDomain() = User(
    id = id,
    name = "$firstName $lastName",
    email = email
)

Cachestrategie in Data Layer: RepositoryImpl controleert eerst de lokale opslag, bij afwezigheid van gegevens — laadt van het netwerk en slaat lokaal op. Als het netwerk niet beschikbaar is — retourneert verouderde gegevens met de markering isStale. Use Case in Domain weet niets van de strategie — ontvangt User via Repository.getUser(id). Wijziging van strategie (bijvoorbeeld cache-invalidatie elke 15 minuten) heeft geen invloed op Domain en Presentation.

Modulariteit in Android — Kotlin Multiplatform maakt het mogelijk Domain naar een aparte KMP-module te verplaatsen zonder afhankelijkheden van Android SDK. Data — aparte module met afhankelijkheid van Domain. Presentation — Android-module met afhankelijkheid van Domain. Gradle-afhankelijkheden: domain (pure Kotlin), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Een dergelijke modulariteit is verplicht voor grote projecten — CI bouwt Domain apart, eenheidstests van Domain vereisen geen Android-emulator.

Presentation Layer: ViewModels en Views

Presentation Layer — de buitenste laag van Clean Architecture. Bevat ViewModels (Android) / ObservableObject (iOS) en Views (Compose/SwiftUI). ViewModel roept Use Case aan, ontvangt het resultaat en zet het om in UI-status (State). View abonneert zich op State en geeft weer. Presentation is afhankelijk van Domain — importeert Use Cases en Entities. Presentation importeert Data Layer niet — gegevens komen via Use Case, die intern Repository gebruikt.

ViewModel in Clean Architecture bevat geen bedrijfslogica — roept Use Case aan. Als Use Case User retourneert, zet ViewModel deze om in UserDisplayItem (name, emailFormatted, avatarUrl) — een puur presentationeel model. Use Case weet niets van DisplayItem — retourneert Entity. Deze scheiding maakt het mogelijk Use Case zonder UI en ViewModel zonder UseCase (via mock) te testen. Bij IT Sectr houden we strikt aan: Use Case — bedrijfslogica, ViewModel — alleen presentatie, View — alleen weergave.

kotlin
// Use Case (Domain) — pure bedrijfslogica
class GetUserUseCase(
    private val repository: UserRepository
) {
    suspend operator fun invoke(id: Int): User {
        return repository.getUser(id)
    }
}

// ViewModel (Presentation) — alleen presentatie
class UserViewModel(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {

    private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
    val state: StateFlow<UserScreenState> = _state.asStateFlow()

    fun loadUser(id: Int) {
        viewModelScope.launch {
            _state.value = UserScreenState.Loading
            val user = getUserUseCase(id)
            val displayItem = UserDisplayItem(
                name = user.name,
                email = user.email,
                initials = user.name.split(" ").joinToString("") { it.first().toString() }
            )
            _state.value = UserScreenState.Success(displayItem)
        }
    }
}

data class UserDisplayItem(
    val name: String,
    val email: String,
    val initials: String
)

sealed interface UserScreenState {
    data object Loading : UserScreenState
    data class Success(val displayItem: UserDisplayItem) : UserScreenState
    data class Error(val message: String) : UserScreenState
}

Navigation in de Presentation-laag — ook onderdeel van de buitenste ring. Clean Architecture schrijft geen navigatiemechanisme voor — dit kan NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) of Router (VIPER) zijn. Belangrijk: de navigatiebeslissing wordt genomen door Presentation, maar navigatie mag niet in Use Case doordringen. Use Case retourneert het resultaat, ViewModel beslist naar welk scherm te gaan. In Clean Architecture is navigatie een detail dat kan worden vervangen zonder Domain te wijzigen.

Clean Architecture op iOS en Android: codevoorbeelden

Clean Architecture op Android wordt geïmplementeerd via Gradle-modules: domain (pure Kotlin), data (domain + Retrofit + Room), presentation (domain + Compose). Mapstructuur: domain/user/User.kt, GetUserUseCase.kt, UserRepository.kt; data/remote/UserRemoteDataSource.kt, local/UserDao.kt, repository/UserRepositoryImpl.kt; presentation/ui/user/UserViewModel.kt, UserScreen.kt. DI (Hilt) verbindt de lagen: UserRepositoryImpl wordt gekoppeld aan de UserRepository-interface in de domain-module.

Clean Architecture op iOS gebruikt SPM of Xcode-groepen zonder aparte modules (vanwege Xcode-beperkingen). Domain — map met bestanden die UIKit of SwiftUI niet importeren. Data — map met APIClient, CoreDataStack, RepositoryImpl. Presentation — map met ViewModels en SwiftUI Views. DI via constructor of assembly in de App. De hoofdaanroep — async/await via UseCase.execute() met MainActor-controle voor UI-updates.

swift
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
    private let apiClient: APIClient

    func fetchUser(id: Int) async throws -> UserDTO {
        return try await apiClient.get("/users/\(id)")
    }
}

// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
    private let remote: UserRemoteDataSource
    private let local: UserLocalDataSource

    func getUser(id: Int) async throws -> User {
        if let cached = try await local.getUser(id) {
            return cached
        }
        let dto = try await remote.fetchUser(id)
        let user = dto.toDomain()
        try await local.saveUser(user)
        return user
    }
}

// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
    @Published private(set) var state: UserScreenState = .loading
    private let getUserUseCase: GetUserUseCase

    func loadUser(id: Int) {
        Task {
            state = .loading
            if let user = try? await getUserUseCase.execute(id: id) {
                state = .success(user)
            } else {
                state = .error("Failed to load")
            }
        }
    }
}

Clean Architecture in IT Sectr-projecten — onze standaard voor projecten vanaf 30 dagen. We gebruiken een drielaagse architectuur met Kotlin Multiplatform voor Android/iOS sinds 2022. Domain — gedeelde KMP-module, Data — platformmodules (Retrofit op Android, URLSession op iOS), Presentation — native UI. Dit geeft 60–80% gedeelde code van bedrijfslogica tussen iOS en Android, wat de ontwikkeltijd met 30–40% verkort vergeleken met twee aparte implementaties.

Veelgestelde vragen

Hoeveel lagen moeten er in Clean Architecture zijn?

Minimaal drie: Domain, Data, Presentation. Voor grote projecten worden Framework (Android SDK/iOS UIKit-afhankelijkheden) en Device (GPS, camera, sensoren) toegevoegd. Het aantal lagen — geen strikte regel, maar een kwestie van gemak. Belangrijk is het naleven van Dependency Rule: afhankelijkheden naar binnen, naar Domain. Je kunt met drie beginnen en lagen toevoegen naarmate het project groeit.

Verhoogt Clean Architecture de hoeveelheid code?

Ja — met 30–50% vergeleken met MVVM vanwege het scheiden van repository-interfaces, Use Cases en mappers. Voor een eenvoudige CRUD-applicatie is dit overbodig. Clean Architecture is gerechtvaardigd voor projecten met complexe bedrijfslogica, waar testbaarheid en laagisolatie belangrijker zijn dan ontwikkelsnelheid. Gebruik MVVM voor MVP of prototype — Clean Architecture vertraagt de lancering.

Kan Clean Architecture worden gecombineerd met MVI?

Ja, dit is een gangbare praktijk. Use Cases blijven in Domain en Presentation gebruikt de MVI-cyclus (Intent → Reducer → State). Data Layer — hetzelfde, Domain — hetzelfde. MVI in Presentation geeft een voorspelbare schermstatus, Clean Architecture — isolatie van bedrijfslogica. Deze combinatie wordt gebruikt in grote projecten met tientallen ontwikkelaars.

Zijn Use Cases nodig voor elke gegevensaanvraag?

Use Case is nodig wanneer de operatie een bedrijfsregel omvat: validatie, combineren van gegevens uit twee bronnen, berekening, loggen, controle van toegangsrechten. Een eenvoudige getUser(id)-aanvraag zonder extra logica kan Repository rechtstreeks vanuit ViewModel aanroepen. Voor uniformiteit van de architectuur maken veel teams echter een Use Case voor elke openbare methode van Repository — dit voegt 5–10% code toe maar vereenvoudigt het lezen.

Hoe test je Clean Architecture?

Domain: eenheidstests van Use Cases met mock Repository — pure Kotlin/Swift zonder Android SDK. Data: integratietests van RepositoryImpl met mock/fake DataSource. Presentation: ViewModel-tests met mock UseCase. Dankzij Dependency Rule wordt elke laag geïsoleerd getest. Bij IT Sectr bereikt de dekkingsgraad van Domain 95%, Data — 70–80%, Presentation — 60–70%.

Samenvatting

  • Clean Architecture — drie lagen (Domain, Data, Presentation) met Dependency Rule naar binnen
  • Dependency Rule — Domain weet niets van Data en Presentation, isolatie via interfaces
  • Domain — Entities, Use Cases, Repository Interfaces — pure Kotlin/Swift zonder frameworks
  • Data — RepositoryImpl, DataSources (netwerk, database, cache) — implementatie van Domain-interfaces
  • Presentation — ViewModels, Views — alleen weergave, bedrijfslogica in Use Cases
  • Testen — Domain gedekt met eenheidstests 90–95%
  • KMP — Clean Architecture met Kotlin Multiplatform geeft 60–80% gedeelde code iOS + Android

We ontwikkelen een mobiele applicatie turnkey

IT Sectr creëert sinds 2017 iOS- en Android-applicaties voor startups en bedrijven. We adviseren u en stellen de beste oplossing voor.

Bespreek het project

Lees ook