Clean Architecture — un'architettura a strati proposta da Robert Martin (Uncle Bob) nel 2012, che divide un'applicazione in strati indipendenti: Domain (Entities, Use Cases), Data (Repositories, DataSources) e Presentation (ViewModels, Views). Il principio principale è la Dependency Rule: le dipendenze puntano verso l'interno, gli strati esterni dipendono da quelli interni, ma non viceversa. La Clean Architecture viene utilizzata nello sviluppo mobile per progetti con elevata complessità di logica di business. Maggiori dettagli nel libro The Clean Architecture.
Punti Chiave
Clean Architecture — un pattern architetturale formulato da Robert Martin (Uncle Bob) nel 2012. L'idea centrale è dividere un'applicazione in strati con una regola di dipendenza rigorosa: il codice all'interno di uno strato non sa nulla del codice esterno. Gli strati esterni (UI, framework, DB) sono dettagli di implementazione. Gli strati interni (logica di business, regole aziendali) sono l'essenza dell'applicazione.
Strati della Clean Architecture nello sviluppo mobile: 1) Domain — Entities (oggetti di business) e Use Cases (scenari d'uso); 2) Data — RepositoryImpl (implementazioni repository), DataSources (rete, DB, cache); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain è lo strato più interno senza dipendenze. Data dipende da Domain (implementa le interfacce repository). Presentation dipende da Domain (chiama Use Cases, si sottoscrive ai risultati).
| Strato | Contiene | Dipendenze |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | Nessuna (Kotlin/Swift puro) |
| Data | RepositoryImpl, DataSources (API, DB, Cache) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — l'unica regola rigida della Clean Architecture. Il codice sorgente può fare riferimento solo a uno strato al suo interno o a uno strato inferiore (più vicino al centro). Presentation importa Domain. Domain NON importa Data o Presentation. Ciò si ottiene tramite il Principio di Inversione delle Dipendenze: Domain definisce l'interfaccia Repository, Data la implementa. Presentation dipende dall'astrazione UseCase, non da un repository specifico.
Domain — lo strato più stabile dell'applicazione. Le Entities sono oggetti di business indipendenti dai framework: User, Product, Order. Gli Use Cases sono classi con un unico metodo invoke (o operator fun invoke in Kotlin), che implementano uno scenario: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Le Repository Interfaces sono astrazioni di accesso ai dati definite in Domain, implementate in Data. Domain non contiene Android SDK, iOS UIKit, Retrofit, Room — solo Kotlin o Swift puro.
// Entity — oggetto di business (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — astrazione dati (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — uno 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 — «una classe con un metodo» non è un dogma ma una raccomandazione pratica. Quando un Use Case diventa più complesso (validazione + logging + chiamata repository), i suoi metodi vengono raggruppati per significato: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. L'importante è che un Use Case non sappia da dove provengono i dati (rete, DB, cache) né chi li visualizza (Compose, SwiftUI). In IT Sectr assegniamo un Use Case per ogni operazione che ha una regola di business, validazione o combinazione di dati da due fonti.
Purezza del Domain si ottiene tramite il mapping DTO ai confini degli strati. Lo strato Data riceve modelli JSON (DTO), li mappa in Entity del Domain. Presentation riceve Entity del Domain, le mappa in ViewModel (DisplayItem). Un'Entity del Domain non contiene mai annotazioni Retrofit, Room o Codable — questo garantisce che lo strato non dovrà essere modificato quando si cambia DB da Room a Realm o quando si sostituisce Retrofit con Ktor.
Data Layer — implementazione delle interfacce definite in Domain. Contiene RepositoryImpl (classi che implementano UserRepository) e DataSources (RemoteDataSource — API, LocalDataSource — DB, CacheDataSource — SharedPreferences/NSUserDefaults). Lo strato Data dipende da Domain (importa interfacce repository ed Entities) e dai framework (Retrofit, Room, Ktor, CoreData). RepositoryImpl nasconde la fonte dei dati a Domain — l'Use Case non sa se i dati provengono dalla rete o dalla cache.
// DTO — modello per rete (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 — implementazione (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Tentativo di ottenere dalla cache
localDataSource.getUser(id)?.let { return it.toDomain() }
// Se non presente — carica dalla rete
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 — conversione DTO ↔ Domain
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Strategia di Cache nel Data Layer: RepositoryImpl controlla prima l'archiviazione locale; se non trova dati, carica dalla rete e salva localmente. Se la rete non è disponibile, restituisce dati obsoleti con un flag isStale. L'Use Case in Domain non conosce la strategia — riceve User tramite Repository.getUser(id). Cambiare la strategia (ad esempio, invalidamento della cache ogni 15 minuti) non influisce su Domain e Presentation.
Modularità in Android — Kotlin Multiplatform consente di estrarre Domain in un modulo KMP separato senza dipendenze Android SDK. Data è un modulo separato con una dipendenza da Domain. Presentation è un modulo Android con una dipendenza da Domain. Dipendenze Gradle: domain (Kotlin puro), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Tale modularità è essenziale per i grandi progetti — CI compila Domain separatamente, i test unitari di Domain non richiedono un emulatore Android.
Presentation Layer — lo strato più esterno della Clean Architecture. Contiene ViewModels (Android) / ObservableObject (iOS) e Views (Compose/SwiftUI). Il ViewModel chiama un Use Case, riceve il risultato e lo trasforma in stato UI. La Vista si sottoscrive allo Stato e lo renderizza. Presentation dipende da Domain — importa Use Cases ed Entities. Presentation non importa il Data Layer — i dati arrivano tramite l'Use Case, che internamente usa un Repository.
ViewModel nella Clean Architecture non contiene logica di business — chiama l'Use Case. Se un Use Case restituisce User, il ViewModel lo trasforma in UserDisplayItem (name, emailFormatted, avatarUrl) — un modello puramente di presentazione. L'Use Case non conosce DisplayItem — restituisce un'Entity. Questa separazione consente di testare l'Use Case senza UI e il ViewModel senza UseCase (tramite mock). In IT Sectr seguiamo rigorosamente: Use Case — logica di business, ViewModel — solo presentazione, Vista — solo visualizzazione.
// Use Case (Domain) — logica di business pura
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — solo presentazione
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
}
Navigazione nel Presentation Layer fa anch'essa parte dell'anello esterno. Clean Architecture non prescrive un meccanismo di navigazione — può essere NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) o Router (VIPER). È importante che le decisioni di navigazione siano prese da Presentation, ma la navigazione non deve penetrare nell'Use Case. L'Use Case restituisce un risultato, il ViewModel decide verso quale schermata navigare. In Clean Architecture, la navigazione è un dettaglio che può essere sostituito senza modificare Domain.
Clean Architecture su Android è implementata tramite moduli Gradle: domain (Kotlin puro), data (domain + Retrofit + Room), presentation (domain + Compose). Struttura delle cartelle: 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) collega gli strati: UserRepositoryImpl è legato all'interfaccia UserRepository nel modulo domain.
Clean Architecture su iOS usa SPM o gruppi Xcode senza moduli separati (a causa delle limitazioni di Xcode). Domain — una cartella con file che non importano UIKit o SwiftUI. Data — una cartella con APIClient, CoreDataStack, RepositoryImpl. Presentation — una cartella con ViewModels e SwiftUI Views. DI tramite costruttore o assemblaggio nell'App. La chiamata principale è async/await tramite UseCase.execute() con controllo MainActor per gli aggiornamenti 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 nei progetti IT Sectr — il nostro standard per progetti a partire da 30 giorni. Utilizziamo un'architettura a tre strati con Kotlin Multiplatform per Android/iOS dal 2022. Domain — un modulo KMP condiviso, Data — moduli di piattaforma (Retrofit su Android, URLSession su iOS), Presentation — UI nativa. Ciò fornisce il 60–80% di codice di logica di business condiviso tra iOS e Android, riducendo i tempi di sviluppo del 30–40% rispetto a due implementazioni separate.
Domande frequenti
Almeno tre: Domain, Data, Presentation. Per i grandi progetti si aggiungono Framework (dipendenze Android SDK/iOS UIKit) e Device (GPS, fotocamera, sensori). Il numero di strati non è una regola rigida ma una questione di convenienza. L'importante è seguire la Dependency Rule: le dipendenze puntano verso l'interno, verso Domain. Puoi iniziare con tre e aggiungere strati man mano che il progetto cresce.
Sì — del 30–50% rispetto a MVVM a causa dell'estrazione delle interfacce repository, Use Cases e mapper. Ciò è eccessivo per una semplice applicazione CRUD. La Clean Architecture è giustificata per progetti con logica di business complessa dove la testabilità e l'isolamento degli strati sono più importanti della velocità di sviluppo. Per MVP o prototipi, usa MVVM — la Clean Architecture rallenterà l'avvio.
Sì, è una pratica comune. Gli Use Cases rimangono in Domain, mentre Presentation utilizza il ciclo MVI (Intent → Reducer → State). Il Data Layer rimane lo stesso, Domain rimane lo stesso. MVI in Presentation fornisce uno stato dello schermo prevedibile, la Clean Architecture fornisce isolamento della logica di business. Questa combinazione viene utilizzata in grandi progetti con decine di sviluppatori.
Un Use Case è necessario quando l'operazione coinvolge una regola di business: validazione, combinazione di dati da due fonti, calcolo, logging, verifica dei permessi di accesso. Una semplice richiesta getUser(id) senza logica aggiuntiva può chiamare il Repository direttamente dal ViewModel. Tuttavia, per coerenza architetturale, molti team creano un Use Case per ogni metodo pubblico del Repository — questo aggiunge il 5–10% di codice ma semplifica la lettura.
Domain: Test unitari degli Use Cases con Repository mock — Kotlin/Swift puro senza Android SDK. Data: Test di integrazione di RepositoryImpl con DataSource mock/fake. Presentation: Test di ViewModel con UseCase mock. Grazie alla Dependency Rule, ogni strato viene testato isolatamente. In IT Sectr, la copertura di Domain raggiunge il 95%, Data — 70–80%, Presentation — 60–70%.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche