Clean Architecture — wielowarstwowa architektura zaproponowana przez Roberta Martina (Uncle Bob) w 2012 roku, dzieląca aplikację na niezależne warstwy: Domain (Entities, Use Cases), Data (Repositories, DataSources) i Presentation (ViewModels, Views). Główna zasada — Dependency Rule: zależności skierowane do wewnątrz, zewnętrzne warstwy zależą od wewnętrznych, ale nie odwrotnie. Clean Architecture jest stosowana w programowaniu mobilnym dla projektów o wysokiej złożoności logiki biznesowej. Więcej — w książce The Clean Architecture.
Najważniejsze
Clean Architecture — wzorzec architektoniczny sformułowany przez Roberta Martina (Uncle Bob) w 2012 roku. Główna idea — podział aplikacji na warstwy ze ścisłą regułą zależności: kod wewnątrz warstwy nie wie o kodzie na zewnątrz. Zewnętrzne warstwy (UI, frameworki, bazy danych) — szczegóły implementacji. Wewnętrzne warstwy (logika biznesowa, reguły przedsiębiorstwa) — istota aplikacji.
Warstwy Clean Architecture w programowaniu mobilnym: 1) Domain — Entities (obiekty biznesowe) i Use Cases (przypadki użycia); 2) Data — RepositoryImpl (implementacja repozytoriów), DataSources (sieć, baza danych, pamięć podręczna); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain — najbardziej wewnętrzna warstwa, nieposiadająca zależności. Data zależy od Domain (implementuje interfejsy repozytoriów). Presentation zależy od Domain (wywołuje Use Cases, subskrybuje wynik).
| Warstwa | Zawiera | Zależności |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | Brak (czysty Kotlin/Swift) |
| Data | RepositoryImpl, DataSources (API, DB, Cache) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — jedyna ścisła zasada Clean Architecture. Kod źródłowy może odwoływać się tylko do warstwy wewnątrz siebie lub do warstwy poniżej (bliżej centrum). Presentation importuje Domain. Domain NIE importuje Data ani Presentation. Osiąga się to przez odwrócenie zależności (Dependency Inversion Principle): Domain definiuje interfejs Repository, Data go implementuje. Presentation zależy od abstrakcji UseCase, a nie od konkretnego repozytorium.
Domain — najbardziej stabilna warstwa aplikacji. Entities — obiekty biznesowe, niezależne od frameworków: User, Product, Order. Use Cases — klasy z jedną metodą invoke (lub operator fun invoke w Kotlin), implementujące jeden scenariusz: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces — abstrakcje dostępu do danych, definiowane w Domain, implementowane w Data. Domain nie zawiera Android SDK, iOS UIKit, Retrofit, Room — tylko czysty Kotlin lub Swift.
// Entity — obiekt biznesowy (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — abstrakcja danych (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — jeden scenariusz (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 — «klasa z jedną metodą» — nie dogmat, a praktyczne zalecenie. Gdy Use Case staje się bardziej złożony (walidacja + logowanie + wywołanie repozytorium), jego metody grupuje się według znaczenia: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. Najważniejsze — Use Case nie powinien wiedzieć, skąd pochodzą dane (sieć, baza danych, pamięć podręczna) i kto je wyświetla (Compose, SwiftUI). W IT Sectr wydzielamy Use Case dla każdej operacji, która ma regułę biznesową, walidację lub łączenie danych z dwóch źródeł.
Czystość Domain osiąga się przez mapowanie DTO na granicach warstw. Warstwa Data otrzymuje modele JSON (DTO), mapuje je na Entity Domain. Presentation otrzymuje Entity Domain, mapuje na ViewModel (DisplayItem). Domain Entity nigdy nie zawiera adnotacji Retrofit, Room, Codable — gwarantuje to, że warstwa nie będzie wymagać zmiany przy przejściu z bazy Room na Realm lub przy zamianie Retrofit na Ktor.
Data Layer — implementacja interfejsów zdefiniowanych w Domain. Zawiera RepositoryImpl (klasy implementujące UserRepository) i DataSources (RemoteDataSource — API, LocalDataSource — baza danych, CacheDataSource — SharedPreferences/NSUserDefaults). Warstwa Data zależy od Domain (importuje interfejsy repozytoriów i Entities) oraz od frameworków (Retrofit, Room, Ktor, CoreData). RepositoryImpl ukrywa przed Domain źródło danych — Use Case nie wie, czy dane pochodzą z sieci czy pamięci podręcznej.
// DTO — model dla sieci (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 — implementacja (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Próbujemy pobrać z pamięci podręcznej
localDataSource.getUser(id)?.let { return it.toDomain() }
// Jeśli nie ma — ładujemy z sieci
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 — konwersja DTO ↔ Domain
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Strategia buforowania w Data Layer: RepositoryImpl najpierw sprawdza lokalne przechowywanie, w przypadku braku danych — ładuje z sieci i zapisuje lokalnie. Jeśli sieć jest niedostępna — zwraca nieaktualne dane z oznaczeniem isStale. Use Case w Domain nie wie o strategii — otrzymuje User przez Repository.getUser(id). Zmiana strategii (np. unieważnianie pamięci podręcznej co 15 minut) nie wpływa na Domain i Presentation.
Modułowość w Android — Kotlin Multiplatform pozwala wydzielić Domain do osobnego modułu KMP bez zależności od Android SDK. Data — osobny moduł z zależnością od Domain. Presentation — moduł Android z zależnością od Domain. Zależności Gradle: domain (pure Kotlin), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Taka modułowość jest obowiązkowa dla dużych projektów — CI buduje Domain osobno, testy jednostkowe Domain nie wymagają emulatora Androida.
Presentation Layer — najbardziej zewnętrzna warstwa Clean Architecture. Zawiera ViewModels (Android) / ObservableObject (iOS) i Views (Compose/SwiftUI). ViewModel wywołuje Use Case, otrzymuje wynik i przekształca go w stan UI (State). View subskrybuje State i wyświetla. Presentation zależy od Domain — importuje Use Cases i Entities. Presentation nie importuje Data Layer — dane przychodzą przez Use Case, który wewnętrznie używa Repository.
ViewModel w Clean Architecture nie zawiera logiki biznesowej — wywołuje Use Case. Jeśli Use Case zwraca User, ViewModel przekształca go w UserDisplayItem (name, emailFormatted, avatarUrl) — czysto prezentacyjny model. Use Case nie wie o DisplayItem — zwraca Entity. To rozdzielenie pozwala testować Use Case bez UI i ViewModel bez UseCase (przez mock). W IT Sectr ściśle przestrzegamy: Use Case — logika biznesowa, ViewModel — tylko prezentacja, View — tylko wyświetlanie.
// Use Case (Domain) — czysta logika biznesowa
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — tylko prezentacja
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 w warstwie Presentation — również część zewnętrznego pierścienia. Clean Architecture nie narzuca mechanizmu nawigacji — może to być NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) lub Router (VIPER). Ważne: decyzję o nawigacji podejmuje Presentation, ale nawigacja nie powinna przenikać do Use Case. Use Case zwraca wynik, ViewModel decyduje, na który ekran przejść. W Clean Architecture nawigacja jest szczegółem, który można wymienić bez zmiany Domain.
Clean Architecture na Android jest realizowana przez moduły Gradle: domain (pure Kotlin), data (domain + Retrofit + Room), presentation (domain + Compose). Struktura folderów: 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) łączy warstwy: UserRepositoryImpl jest przypisany do interfejsu UserRepository w module domain.
Clean Architecture na iOS używa SPM lub grup Xcode bez osobnych modułów (z powodu ograniczeń Xcode). Domain — folder z plikami nieimportującymi UIKit ani SwiftUI. Data — folder z APIClient, CoreDataStack, RepositoryImpl. Presentation — folder z ViewModels i SwiftUI Views. DI przez konstruktor lub assembly w App. Główne wywołanie — async/await przez UseCase.execute() z sprawdzeniem MainActor dla aktualizacji UI.
// 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 w projektach IT Sectr — nasz standard dla projektów od 30 dni. Używamy trójwarstwowej architektury z Kotlin Multiplatform dla Android/iOS od 2022 roku. Domain — wspólny moduł KMP, Data — moduły platformowe (Retrofit na Android, URLSession na iOS), Presentation — natywne UI. Daje to 60–80% wspólnego kodu logiki biznesowej między iOS a Android, skracając czas rozwoju o 30–40% w porównaniu z dwiema osobnymi implementacjami.
Często zadawane pytania
Minimalnie trzy: Domain, Data, Presentation. Dla dużych projektów dodaje się Framework (zależności Android SDK/iOS UIKit) i Device (GPS, kamera, czujniki). Liczba warstw — nie sztywna reguła, a kwestia wygody. Najważniejsze — przestrzegać Dependency Rule: zależności skierowane do wewnątrz, ku Domain. Można zacząć od trzech i dodawać warstwy w miarę wzrostu projektu.
Tak — o 30–50% w porównaniu z MVVM z powodu wydzielenia interfejsów repozytoriów, Use Cases i mapperów. Dla prostej aplikacji CRUD to nadmiarowe. Clean Architecture jest uzasadniona dla projektów ze złożoną logiką biznesową, gdzie testowalność i izolacja warstw są ważniejsze niż szybkość rozwoju. Dla MVP lub prototypu używaj MVVM — Clean Architecture spowolni uruchomienie.
Tak, to powszechna praktyka. Use Cases pozostają w Domain, a Presentation używa cyklu MVI (Intent → Reducer → State). Data Layer — ten sam, Domain — ten sam. MVI w Presentation daje przewidywalny stan ekranu, Clean Architecture — izolację logiki biznesowej. Taka kombinacja jest używana w dużych projektach z dziesiątkami programistów.
Use Case jest potrzebny, gdy operacja zawiera regułę biznesową: walidację, łączenie danych z dwóch źródeł, obliczenia, logowanie, sprawdzanie uprawnień. Proste zapytanie getUser(id) bez dodatkowej logiki może wywoływać Repository bezpośrednio z ViewModel. Jednak dla jednolitości architektury wiele zespołów tworzy Use Case dla każdej publicznej metody Repository — dodaje to 5–10% kodu, ale upraszcza czytanie.
Domain: testy jednostkowe Use Cases z mock Repository — czysty Kotlin/Swift bez Android SDK. Data: testy integracyjne RepositoryImpl z mock/fake DataSource. Presentation: testy ViewModel z mock UseCase. Dzięki Dependency Rule każda warstwa jest testowana w izolacji. W IT Sectr pokrycie Domain osiąga 95%, Data — 70–80%, Presentation — 60–70%.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również