Arquitectura Clean Architecture + MVVM en SwiftUI: Estructura un proyecto escalable sin librerías de terceros

Todos hemos empezado un proyecto en SwiftUI con una vista, un par de llamadas a la API dentro y la sensación de que vamos rapidísimo. Unos meses después, esa vista tiene 600 líneas, mezcla red, lógica y diseño, y tocar cualquier cosa da miedo.

Clean Architecture con MVVM es una forma de evitarlo sin añadir ni una dependencia. En este artículo montamos un ejemplo completo y preparado para Swift 6: capas, repositorio, caso de uso, ViewModel con @Observable, inyección de dependencias y hasta un test.


¿Por qué combinar Clean Architecture y MVVM en SwiftUI?

SwiftUI encaja de forma natural con el patrón MVVM. La vista observa al ViewModel y se redibuja automáticamente cuando el estado cambia. No obstante, MVVM solo resuelve la capa de presentación; no te dice dónde colocar la lógica de negocio compleja, el almacenamiento local o la sincronización con APIs externas. Si dejas todo en manos del ViewModel, terminarás con el temido antipatrón Massive ViewModel.

Aquí es donde entra Clean Architecture, propuesta originalmente por Robert C. Martin (Uncle Bob). Esta arquitectura impone la Regla de Dependencia: el código interno nunca debe conocer nada sobre el código externo. Al fusionar ambos enfoques:

  • MVVM gobierna la capa de presentación (UI y estado de la pantalla).
  • Clean Architecture gobierna las capas de dominio y datos (lógica de negocio, casos de uso y repositorios).

El resultado es un código desacoplado, modular, escalable y con una testabilidad cercana al 100%.


Las 3 Capas Fundamentales de la Arquitectura

Para implementar este enfoque sin librerías de terceros, dividiremos nuestro proyecto en tres capas concéntricas claramente diferenciadas:

[ Capa de Presentación (SwiftUI View + ViewModel) ]
                    ↓ (depende de)
    [ Capa de Dominio (Use Cases + Entities) ]
                    ↑ (implementa interfaces de)
       [ Capa de Datos (Repositories + DataSources) ]

1. Capa de Dominio (Domain Layer)

Es el núcleo del software y la capa más aislada. No depende de SwiftUI, ni de CoreData, ni de URLSession. Contiene:

  • Entidades: Modelos de negocio puros.
  • Casos de Uso (Use Cases / Interactors): Acciones concretas del usuario o del sistema (ejemplo: GetUserProfileUseCase).
  • Interfaces de Repositorio: Protocolos que definen qué datos se necesitan sin especificar de dónde provienen.

2. Capa de Datos (Data Layer)

Esta capa es responsable de suministrar la información requerida por el dominio. Implementa las interfaces definidas en la capa central. Contiene:

  • Implementación de Repositorios: Coordinan las fuentes de datos.
  • Data Sources: Clases que interactúan directamente con servicios externos (APIs REST mediante URLSession, bases de datos locales con SwiftData o almacenamiento en disco).
  • Modelos DTO (Data Transfer Objects): Estructuras decodificables (Decodable) que mapean las respuestas externas antes de convertirlas a entidades de dominio.

3. Capa de Presentación (Presentation Layer)

Es la cara visible de la aplicación construida con SwiftUI. Contiene:

  • Vistas (Views): Vistas declarativas que representan el estado actual de la pantalla. No toman decisiones lógicas.
  • ViewModels: Clases que transforman las entidades del dominio en estados consumibles por la UI y ejecutan los casos de uso.

Estructura de Carpetas para un Proyecto Escalable

Una estructura de carpetas limpia y coherente es el reflejo inmediato de una buena arquitectura. Puedes organizar tu proyecto en Xcode de la siguiente manera:

  • Core/
    • Network/ (Configuraciones nativas de URLSession)
    • Extensions/
  • Domain/
    • Entities/
    • UseCases/
    • Repositories/ (Protocolos únicamente)
  • Data/
    • Repositories/ (Implementaciones de los protocolos)
    • DataSources/
      • Remote/ (API Clients)
      • Local/ (Caché, UserDefaults, SwiftData)
    • Models/ (DTOs y mappers a entidades)
  • Presentation/
    • Scenes/
      • ProductsList/
        • Views/
        • ViewModels/
      • ProductDetail/
  • DI/
    • AppContainer.swift (Inyector de dependencias nativo)

Implementación Práctica Paso a Paso (Sin Dependencias)

Veamos cómo conectar todas estas piezas utilizando las herramientas nativas de Swift moderno (async/await y @Observable). El código está preparado para el modo de concurrencia estricta de Swift 6: los protocolos son Sendable y el ViewModel y el contenedor viven en @MainActor, así que el compilador no te dará avisos de data races.

Paso 1: Capa de Dominio

Primero, definimos la entidad pura y el contrato del repositorio mediante un protocolo:

// Domain/Entities/Product.swift
struct Product: Identifiable, Equatable, Sendable {
    let id: Int
    let title: String
    let price: Double
}

// Domain/Repositories/ProductRepositoryProtocol.swift
protocol ProductRepositoryProtocol: Sendable {
    func fetchProducts() async throws -> [Product]
}

// Domain/UseCases/GetProductsUseCase.swift
protocol GetProductsUseCaseProtocol: Sendable {
    func execute() async throws -> [Product]
}

struct GetProductsUseCase: GetProductsUseCaseProtocol {
    private let repository: ProductRepositoryProtocol

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

    func execute() async throws -> [Product] {
        return try await repository.fetchProducts()
    }
}

Paso 2: Capa de Datos

Implementamos el cliente de red usando exclusivamente la API nativa URLSession:

// Data/Models/ProductDTO.swift
struct ProductDTO: Decodable, Sendable {
    let id: Int
    let title: String
    let price: Double

    // Mapper: convierte la respuesta de la API en una entidad de dominio
    func toDomain() -> Product {
        Product(id: id, title: title, price: price)
    }
}

// Data/DataSources/Remote/ProductRemoteDataSource.swift
protocol ProductRemoteDataSourceProtocol: Sendable {
    func getProducts() async throws -> [ProductDTO]
}

struct ProductRemoteDataSource: ProductRemoteDataSourceProtocol {
    func getProducts() async throws -> [ProductDTO] {
        guard let url = URL(string: "https://api.ejemplo.com/products") else {
            throw URLError(.badURL)
        }
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode([ProductDTO].self, from: data)
    }
}

// Data/Repositories/ProductRepository.swift
struct ProductRepository: ProductRepositoryProtocol {
    private let remoteDataSource: ProductRemoteDataSourceProtocol

    init(remoteDataSource: ProductRemoteDataSourceProtocol) {
        self.remoteDataSource = remoteDataSource
    }

    func fetchProducts() async throws -> [Product] {
        let dtos = try await remoteDataSource.getProducts()
        return dtos.map { $0.toDomain() }
    }
}

Paso 3: Capa de Presentación

A partir de iOS 17, Apple introdujo la macro @Observable, que simplifica drásticamente el manejo del estado sin necesidad de importar librerías complejas:

// Presentation/Scenes/ProductsList/ViewModels/ProductsListViewModel.swift
import Foundation

@MainActor
@Observable
final class ProductsListViewModel {
    private let getProductsUseCase: GetProductsUseCaseProtocol

    var products: [Product] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil

    init(getProductsUseCase: GetProductsUseCaseProtocol) {
        self.getProductsUseCase = getProductsUseCase
    }

    func loadProducts() async {
        isLoading = true
        errorMessage = nil
        do {
            products = try await getProductsUseCase.execute()
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

// Presentation/Scenes/ProductsList/Views/ProductsListView.swift
import SwiftUI

struct ProductsListView: View {
    @State private var viewModel: ProductsListViewModel

    init(viewModel: ProductsListViewModel) {
        _viewModel = State(initialValue: viewModel)
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Cargando productos...")
                } else if let error = viewModel.errorMessage {
                    Text("Error: \(error)")
                        .foregroundStyle(.red)
                } else {
                    List(viewModel.products) { product in
                        VStack(alignment: .leading) {
                            Text(product.title).font(.headline)
                            Text(product.price, format: .currency(code: "EUR")).font(.subheadline)
                        }
                    }
                }
            }
            .navigationTitle("Catálogo")
            .task {
                await viewModel.loadProducts()
            }
        }
    }
}

Gestión de Inyección de Dependencias Nativa

No necesitas frameworks como Swinject o Resolver para conectar estas piezas. Un contenedor de dependencias simple basado en composición nativa de Swift es más que suficiente:

// DI/AppContainer.swift
@MainActor
final class AppContainer {
    static let shared = AppContainer()

    private init() {}

    func makeProductsListViewModel() -> ProductsListViewModel {
        let dataSource = ProductRemoteDataSource()
        let repository = ProductRepository(remoteDataSource: dataSource)
        let useCase = GetProductsUseCase(repository: repository)
        return ProductsListViewModel(getProductsUseCase: useCase)
    }
}

En el punto de entrada de la aplicación (App.swift), solo inyectas la vista raíz:

@main
struct CleanApp: App {
    var body: some Scene {
        WindowGroup {
            ProductsListView(viewModel: AppContainer.shared.makeProductsListViewModel())
        }
    }
}

Ventajas de Prescindir de Librerías de Terceros

Diseñar tu arquitectura apoyándote exclusivamente en las API oficiales de Apple ofrece múltiples beneficios competitivos:

  • Compilaciones más ligeras: Menos dependencias implican menos código que resolver y compilar en cada build limpio.
  • Cero deuda técnica por obsolescencia: No tendrás que reescribir partes del proyecto porque un autor abandonó el repositorio de GitHub.
  • Seguridad y compatibilidad inmediata: Tu aplicación estará preparada desde el día uno para las nuevas versiones de iOS y Xcode.
  • Facilidad absoluta para crear Mocks y Tests: Al utilizar protocolos en cada capa, crear tests unitarios para casos de uso y ViewModels toma solo segundos.

Por ejemplo, con Swift Testing puedes probar el ViewModel con un repositorio falso, sin red ni servidores:

import Testing
@testable import CleanApp

struct MockProductRepository: ProductRepositoryProtocol {
    let products: [Product]
    func fetchProducts() async throws -> [Product] { products }
}

@MainActor
@Test func cargaLosProductos() async {
    let repo = MockProductRepository(products: [Product(id: 1, title: "Camiseta", price: 19.9)])
    let viewModel = ProductsListViewModel(getProductsUseCase: GetProductsUseCase(repository: repo))

    await viewModel.loadProducts()

    #expect(viewModel.products.count == 1)
    #expect(viewModel.errorMessage == nil)
}

Errores típicos que conviene evitar

  • Sobrearquitectura en apps pequeñas. Tres capas y cinco protocolos para una pantalla con una lista es demasiado.
  • Casos de uso que solo reenvían. Si un caso de uso se limita a llamar al repositorio, plantéate si de verdad lo necesitas.
  • DTOs en la vista. La vista solo debería conocer entidades de dominio; si ves un Decodable en SwiftUI, algo se ha colado.
  • Un contenedor de dependencias gigante. Divídelo por funcionalidades antes de que se convierta en otro archivo imposible de mantener.

Conclusión

Clean Architecture no es obligatoria: para una app pequeña o un prototipo, una vista con su ViewModel es más que suficiente. Empieza a compensar cuando el proyecto crece, trabajáis varias personas o necesitas tests fiables.

Si te parece mucho de golpe, no hace falta aplicarlo todo el primer día. Empieza por sacar las llamadas de red de las vistas a un repositorio con protocolo: solo con eso ya ganas buena parte de los beneficios.

Artículos relacionados: Adiós a Alamofire: Cómo estructurar una capa de red moderna en iOS con Swift 6 y Concurrencia Estricta · Consumiendo una API REST en Swift 6: Adiós a las librerías externas · Migración de UIKIt a SwiftUI en 2026: ¿Merece la pena el riesgo?

Deja una respuesta