Capas: dominio, datos y presentación
Separar una app en capas no es ordenar carpetas: es imponer una dirección a las dependencias para que el conocimiento del mundo exterior no suba hasta la interfaz. Esta lección define las tres capas con precisión, explica qué hace un `repository` que no hace un cliente de red, por qué la vista no debe conocer nunca el transporte y dónde esta disciplina se convierte en burocracia.
La palabra capa se usa con una ligereza que le ha quitado casi todo el significado. En muchos proyectos designa simplemente un grupo de carpetas, y entonces no impone nada: los tipos de red aparecen en la vista, el modelo de dominio hereda del formato del servidor y cambiar un campo de la interfaz obliga a tocar el analizador de respuestas. Una capa de verdad es otra cosa, y se reconoce por una propiedad única y verificable: la dirección en la que se permite mirar. Dominio, datos y presentación no son tres cajones donde repartir archivos, sino tres regiones ordenadas por dependencia, y toda la potencia del diseño está en que esa flecha apunte siempre hacia el mismo lado, incluso cuando el atajo contrario ahorraría veinte minutos.
- Definir dominio, datos y presentación por su dirección de dependencia y no por su contenido.
- Distinguir el modelo de dominio del objeto de transferencia que llega del servidor, y saber dónde se traduce.
- Diseñar un
repositorycomo frontera con contrato propio, política de caché y errores de dominio. - Reconocer cuándo esta separación deja de pagar y se convierte en ceremonia vacía.
Las tres capas y la única regla que importa
El dominio contiene los tipos que describen el problema y las reglas que los gobiernan, y no importa nada: ni SwiftUI, ni la red, ni la base de datos. La capa de datos sabe hablar con el mundo, es decir, con URLSession, con SwiftData, con el Keychain, y su misión es entregar tipos de dominio ya traducidos. La presentación observa el dominio y dibuja. La regla es una sola: las dependencias apuntan hacia dentro, hacia el dominio, y nunca hacia fuera.
// Dominio: puro, sin dependencias de framework
struct Nota: Identifiable, Equatable {
let id: UUID
var titulo: String
var cuerpo: String
var esPublicable: Bool { !titulo.isEmpty && cuerpo.count >= 10 }
}
enum NotasError: Error, Equatable {
case sinConexion
case noEncontrada
case rechazadaPorElServidor(motivo: String)
}
// Dominio: el contrato que la capa de datos deberá cumplir
protocol NotasRepository: Sendable {
func todas() async throws -> [Nota]
func guardar(_ nota: Nota) async throws
}
Observa también que esPublicable es una regla, no un campo, y que vive junto a los datos que la determinan. Cuando esa misma regla se escribe en el body de una vista, deja de ser conocimiento del problema y pasa a ser detalle de una pantalla, con el efecto secundario de que la segunda pantalla que la necesite la copiará ligeramente distinta. La capa de dominio es, entre otras cosas, el sitio donde una regla solo puede existir una vez.
El detalle que decide todo está en las dos últimas líneas: el protocolo vive en el dominio, no en la capa de datos. Quien define el contrato es el que lo necesita, y quien lo implementa es el que sabe del mundo. Esa inversión es la que permite compilar y probar el dominio entero sin que exista una sola línea de red en el proyecto.
flowchart TD P[Presentacion vistas y modelos de vista] --> D[Dominio tipos reglas y protocolos] DAT[Datos red base de datos y llavero] --> D DAT -.->|traduce DTO a modelo| D P -.->|nunca conoce el transporte| DAT style D fill:#a6e3a1,color:#11111b style DAT fill:#89b4fa,color:#11111b
El repository como frontera, no como envoltorio
Un repository que se limita a llamar a un endpoint y devolver lo que llegue no es una frontera: es una capa de reenvío con un nombre prestigioso. La frontera aparece cuando el repositorio asume tres responsabilidades que la vista jamás debería ver.
// Capa de datos: conoce el transporte y lo esconde
struct NotaDTO: Decodable {
let id: String
let title: String
let body: String?
}
final class NotasRepositoryHTTP: NotasRepository {
private let cliente: HTTPCliente
private let local: NotasLocalStore
func todas() async throws -> [Nota] {
do {
let dtos: [NotaDTO] = try await cliente.get("/notes")
let notas = dtos.compactMap(Nota.init(dto:)) // traduccion
try await local.reemplazar(con: notas) // politica de cache
return notas
} catch is URLError {
return try await local.todas() // degradacion
} catch let error as HTTPError where error.codigo == 404 {
throw NotasError.noEncontrada // error de dominio
}
}
}
Traducción
El DTO con nombres del servidor y campos opcionales muere aquí. Hacia dentro solo circulan tipos de dominio.
Política de origen
Decide si sirve de la caché, de la red o de ambas. Quien llama pide notas, no elige transporte.
Errores traducidos
Un código 404 se convierte en un caso del dominio. Nadie más arriba interpreta números de protocolo.
Sustituible
El protocolo permite una implementación en memoria para pruebas y previews sin tocar la interfaz.
Por qué la vista no debe conocer la red
El argumento fuerte no es la limpieza estética, sino que cada dato del transporte que se filtra a la interfaz crea un acoplamiento entre dos cosas que cambian a ritmos distintos y por motivos distintos. El servidor cambia cuando lo decide otro equipo, a veces otra empresa; la interfaz cambia cuando lo decide diseño, cada dos semanas. Unir por accidente dos calendarios ajenos garantiza que cada uno de ellos genere trabajo en el otro lado. El servidor renombra un campo o añade un envoltorio de paginación, y de pronto hay que tocar vistas. Además, la vista que conoce la red hereda su vocabulario de fallos, y termina mostrando al usuario mensajes que hablan de códigos y de tiempos de espera en lugar de hablar de notas.
Hay un tercer efecto, menos visible y más costoso a largo plazo: la vista acoplada al transporte no se puede previsualizar ni probar sin simular un servidor, y lo que no se previsualiza barato termina sin previsualizarse. El acoplamiento no solo propaga cambios; también apaga las herramientas que harían el cambio seguro.
| Consecuencia | Vista acoplada al transporte | Vista acoplada al dominio |
|---|---|---|
| Cambio de campo en el servidor | se propaga hasta el body |
muere en la traducción del repository |
| Sustituir la red por caché local | reescritura de pantallas | cambiar la implementación del protocolo |
| Preview y pruebas de interfaz | exigen simular el servidor | reciben una implementación en memoria |
| Mensajes de error al usuario | hablan de códigos y de tiempos | hablan de notas y de conexión |
| Migrar a otra API o a otro backend | proporcional al número de vistas | proporcional al número de repositorios |
Hay un ensayo mecánico que verifica la separación en cinco minutos: borra el archivo del cliente de red y compila. Si dejan de compilar tipos de la carpeta de presentación, tu vista conocía el transporte aunque el diagrama dijera lo contrario. La única frontera real es la que el compilador defiende.
Dónde esta disciplina deja de pagar
Sería deshonesto presentar la separación como gratuita. En una app de tres pantallas que consume una única API estable y que nunca tendrá modo sin conexión, la traducción de DTO a modelo duplica tipos casi idénticos y el protocolo añade un indirecto que nadie sustituirá jamás.
Separar por dependencia y separar por objetivo de compilación son decisiones distintas y se pueden tomar en momentos distintos. Empieza por carpetas y por la disciplina de la flecha; convierte el dominio en un paquete solo cuando quieras que el compilador impida el atajo, que es también cuando empiezas a pagar tiempos de enlazado y configuración. La frontera lógica es barata y reversible; la frontera física no.
El criterio para saber si vale la pena tiene que ver con cuántos consumidores tiene el dato y con cuántas fuentes puede llegar a tener: una entidad leída por una sola pantalla y servida por un solo endpoint no necesita frontera, y una que alimenta la app, el widget y el reloj, y que debe funcionar sin conexión, la necesita desde el primer día.
Conviene además aplicar la separación por entidad y no por proyecto. Nada obliga a que todas las entidades de una app tengan el mismo grado de aduana: la que sostiene el negocio puede tener DTO, modelo, protocolo e implementación en memoria, mientras la que solo alimenta una pantalla de ayuda se decodifica directamente y se olvida. Esa asimetría deliberada es señal de criterio, no de descuido, y es lo que distingue una arquitectura pensada de una plantilla aplicada.
La tentación al hablar de arquitectura por capas es pensarla como una taxonomía, una forma refinada de clasificar archivos por su naturaleza. No lo es, y esa confusión explica por qué tantos proyectos tienen las carpetas correctas y ninguna de las ventajas. Una capa es un contrato de ignorancia deliberada: la promesa de que quien está dentro puede razonar correctamente sin saber nada de lo que ocurre fuera. El dominio no ignora la red por elegancia, sino porque solo ignorándola puede afirmar que una nota es publicable con independencia de cómo llegó, de si vino de la caché o del servidor y de si el servidor la envía con un nombre de campo u otro. Ese poder de ignorar es lo que hace que el conocimiento del dominio sobreviva al cambio del mundo, y lo que convierte el repository en la pieza central del diseño: no es un servicio, es la aduana donde el mundo entrega su desorden y recibe a cambio la obligación de traducirlo a un vocabulario que la app pueda razonar. Por eso la traducción del DTO no es un trámite molesto que un decodificador más listo podría ahorrarte, sino el acto en que se decide qué parte de la realidad exterior entra a tu sistema y bajo qué forma. Y por eso también la dirección de la flecha importa más que el número de capas: puedes tener dos o cinco, puedes fusionar datos y dominio en una app pequeña, pero en el instante en que el conocimiento del transporte sube hasta la vista has perdido lo único que las capas venden, que no es orden sino el derecho a no enterarte de lo que cambia.
- Ejecuta la prueba del apagón: comenta el cliente de red y anota cada archivo de presentación que deje de compilar. Esa lista es tu deuda real.
- Elige la entidad más usada de tu app y sepárala en dos tipos: el
DTOcon la forma exacta del servidor y el modelo de dominio con la forma que necesita tu app. - Declara el protocolo del
repositoryen la capa de dominio y mueve la implementación con red a la capa de datos. Comprueba que el dominio compila solo. - Traduce al menos tres errores de transporte a casos de un enumerado de dominio y reescribe el mensaje que ve el usuario en ese vocabulario.
- Escribe una implementación en memoria del protocolo y úsala en las previews. Si alguna preview necesita servidor, aún queda transporte filtrado.