Capas
En resumen: La arquitectura en capas divide la aplicación en presentación, aplicación, dominio y datos, y las dependencias apuntan solo hacia abajo, así una regla de negocio vive en un solo lugar.
Problema
El descuento en el cobro y en el informe
La tienda da un 10 % de descuento a un carrito de 100 € o más. El cobro calcula el descuento en el controlador que atiende la solicitud. El informe para contabilidad es una consulta SQL sobre la misma tabla, con su propia condición en WHERE:
# El cobro: el controlador lee el carrito y calcula el descuento él mismoclass CheckoutController: def __init__(self, carts): self.carts = carts
def post(self, customer): total = self.carts[customer] if total >= 100: total = total - total // 10 return f"{customer} paga {total} €"
# El informe es una consulta SQL sobre la misma tabla:# SELECT customer FROM carts WHERE subtotal > 100# Aquí la tabla es un diccionario y WHERE es un if.def discounted(carts): return [customer for customer, subtotal in carts.items() if subtotal > 100]carts = {"María": 100, "Lucía": 150, "Pedro": 80}checkout = CheckoutController(carts)for customer in carts: print("Cobro: " + checkout.post(customer))print("Informe: con descuento hay " + ", ".join(discounted(carts)))Cobro: María paga 90 €Cobro: Lucía paga 135 €Cobro: Pedro paga 80 €Informe: con descuento hay LucíaMaría paga 90 €, y el informe dice que no tuvo descuento. El cobro tiene >= 100, la consulta tiene > 100. Contabilidad ve una diferencia de 10 € y nadie sabe qué versión es la correcta.
Hay un segundo dolor. La regla está pegada al controlador: para comprobar si >= 100 es correcto, necesitas una solicitud, un carrito en una tabla y una respuesta que leer. La app móvil querrá una tercera versión.
La idea
Cuatro capas y una dirección
En la lección Repository viste un controlador, un servicio y un repositorio uno debajo del otro. Aquí organizamos así una aplicación entera y le damos una regla. La arquitectura en capas divide el código en cuatro capas:
- presentación: el controlador. Lee la solicitud y muestra la respuesta. No calcula;
- aplicación: el servicio. Conoce los pasos de una tarea («toma el carrito, calcula el precio»), pero no las reglas;
- dominio: las reglas de la tienda.
Pricing.totalconoce «desde 100 € hay un 10 % de descuento» y nada más: ni solicitudes ni tablas; - datos: los repositorios. De dónde viene el carrito.
Las capas tienen una regla: las dependencias apuntan hacia abajo. La capa de arriba llama a la de abajo, y la de abajo no sabe que existe la de arriba. Por eso el dominio puede usarlo el cobro, el informe y la app móvil sin tocarlo.
Una capa no es necesariamente una clase. En un proyecto grande una capa es un paquete o un proyecto con muchas clases dentro.
Míralo
La solicitud a través de las capas
Las cuatro capas del cobro, de arriba abajo:
# Presentación: entra la solicitud, sale la respuestaclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} paga {total} €"
# Aplicación: los pasos de una tareaclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Dominio: la regla de la tienda y nada másclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Datos: de dónde viene el carritoclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...CartRepository es un Protocol: describe lo que debe tener un repositorio, pero nadie hereda de él. Python acepta cualquier objeto que tenga subtotal_of, y un verificador de tipos (mypy) puede avisar pronto si falta.El repositorio en memoria y el informe, que ahora pregunta al dominio en lugar de escribir su propia condición:
class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): return self.subtotals.get(customer, 0)# Aplicación: el informe pregunta al dominio, no tiene regla propiaclass DiscountReport: def __init__(self, carts): self.carts = carts
def discounted(self, customers): names = [] for customer in customers: subtotal = self.carts.subtotal_of(customer) if Pricing.total(subtotal) < subtotal: names.append(customer) return namesTodo junto: el cobro y el informe sobre los mismos datos.
carts = InMemoryCarts()carts.put("María", 100)carts.put("Lucía", 150)carts.put("Pedro", 80)customers = ["María", "Lucía", "Pedro"]
web = CheckoutController(CheckoutService(carts))for customer in customers: print(web.post(customer))report = DiscountReport(carts)print("Con descuento: " + ", ".join(report.discounted(customers)))María paga 90 €Lucía paga 135 €Pedro paga 80 €Con descuento: María, Lucía# Presentación: entra la solicitud, sale la respuestaclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} paga {total} €"
# Aplicación: los pasos de una tareaclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Dominio: la regla de la tienda y nada másclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Datos: de dónde viene el carritoclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...# Presentación: entra la solicitud, sale la respuestaclass CheckoutController: def __init__(self, service): self.service = service
def post(self, customer): total = self.service.checkout(customer) return f"{customer} paga {total} €"
# Aplicación: los pasos de una tareaclass CheckoutService: def __init__(self, carts): self.carts = carts
def checkout(self, customer): subtotal = self.carts.subtotal_of(customer) return Pricing.total(subtotal)
# Dominio: la regla de la tienda y nada másclass Pricing: @staticmethod def total(subtotal): if subtotal < 100: return subtotal return subtotal - subtotal // 10
# Datos: de dónde viene el carritoclass CartRepository(Protocol): def subtotal_of(self, customer: str) -> int: ...class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): subtotal = self.subtotals.get(customer, 0) # el informe quería la suma después del descuento return Pricing.total(subtotal)Paso a paso, en palabras
- Cuatro capas: presentación, aplicación, dominio, datos. Cada una llama solo a las capas de abajo: todas las flechas apuntan hacia abajo.
- La solicitud de cobro de María entra en el controlador. Solo lee la solicitud y muestra la respuesta.
- El controlador pasa la tarea a la capa de aplicación: el servicio conoce los pasos del cobro.
- El servicio pregunta a la capa de datos por el carrito: 100 €. El repositorio da datos y no guarda reglas.
- Luego pregunta al dominio: Pricing conoce la regla «desde 100 € hay un 10 % de descuento». 100 € pasan a ser 90 €.
- La respuesta sube de nuevo al controlador, y este imprime la línea. El informe pregunta al mismo Pricing.
- Ahora alguien hace que el repositorio devuelva la suma después del descuento. La capa de datos llama a Pricing: una flecha hacia arriba.
- El carrito de Lucía: el repositorio da 135 €, y el servicio descuenta otro 10 %. Paga 122 € en lugar de 135 €.
- Hacia abajo se puede, hacia arriba es el error: la regla se filtró otra vez a los datos, como en la consulta SQL del informe.
María paga 90 € y está en el informe, porque los dos sitios preguntan la misma regla. El servicio llama al repositorio sin pasar por el dominio: está permitido, porque el repositorio está debajo. La regla trata solo de la dirección.
Pruébalo
La regla sin nada alrededor
El dominio no sabe nada de controladores ni repositorios, así que se comprueba por separado: sin solicitud, sin carrito, sin base de datos. Su prueba también se ve así, como en la lección Tests con unittest.
¿Qué imprime el programa?
for subtotal in [99, 100, 101]: print(f"{subtotal} € → {Pricing.total(subtotal)} €")El límite está incluido: 100 € ya recibe el descuento. Desde 101 € se descuentan 10 €, porque la división es entera y 101 / 10 es 10. Justo estos límites (99, 100, 101) son la primera prueba de cualquier regla sobre dinero.
Pruébalo
Encuentra el error
El informe quiere la suma después del descuento. Alguien decide que lo más fácil es que el repositorio la entregue ya calculada:
class InMemoryCarts: def __init__(self): self.subtotals = {}
def put(self, customer, subtotal): self.subtotals[customer] = subtotal
def subtotal_of(self, customer): subtotal = self.subtotals.get(customer, 0) # el informe quería la suma después del descuento return Pricing.total(subtotal)El cobro y el informe son los mismos, sin una sola línea cambiada:
carts = InMemoryCarts()carts.put("María", 100)carts.put("Lucía", 150)carts.put("Pedro", 80)customers = ["María", "Lucía", "Pedro"]
web = CheckoutController(CheckoutService(carts))for customer in customers: print(web.post(customer))report = DiscountReport(carts)print("Con descuento: " + ", ".join(report.discounted(customers)))María paga 90 €Lucía paga 122 €Pedro paga 80 €Con descuento: LucíaLucía paga 122 € en lugar de 135 €, y María vuelve a faltar en el informe. ¿En qué línea está el error?
Elige la línea con el error.
La capa de datos llama al dominio: una flecha que apunta hacia arriba. El repositorio ahora conoce la regla, y se aplica dos veces: 150 € → 135 € en el repositorio, luego 135 € → 122 € en el servicio. El informe pregunta «¿están rebajados 90 €?» y dice «no» para María. Es el mismo problema que WHERE en la consulta SQL del principio: una regla que vive en los datos. El repositorio debe devolver solo lo que guarda. La suma después del descuento es trabajo del servicio, que pregunta a Pricing.
La idea
Por técnica o por función
Las capas dicen quién llama a quién. Cómo se organizan los archivos es otra pregunta. La primera manera es por técnica: una carpeta para cada capa.
shop/ web/ checkout.py catalog.py services/ checkout.py catalog.py domain/ pricing.py product.py data/ carts.py products.pyLa segunda es por función (vertical slice): una carpeta para cada parte de la tienda, con las capas dentro.
shop/ checkout/ views.py service.py pricing.py carts.py catalog/ views.py service.py product.py products.pyPor técnica es fácil al principio, pero cada cambio en el cobro toca cuatro carpetas, y la carpeta service/ crece con todo. Por función el cambio está en un solo lugar, y cada carpeta puede tener sus propias reglas sobre quién la llama desde fuera. Ese es el paso hacia los módulos de la lección Monolito, monolito modular, servicios. La regla de la dirección vale en ambos casos.
La idea
Cuando las capas perjudican
Las capas cuestan archivos y llamadas. No compensan cuando:
- no hay reglas. Un formulario que solo guarda en la base de datos pasa por un controlador, un servicio y un dominio que solo pasan las cosas. Esto se llama sinkhole: la solicitud cae a través de las capas y nadie la toca;
- cada capa tiene su propio objeto para los mismos datos:
CartDto,CartModel,CartEntity, y convertir entre ellos es la mitad del código; - todo está organizado por técnica y el proyecto es grande: la carpeta
service/tiene 200 archivos que no tienen nada que ver entre sí.
Compensan cuando hay reglas que deben guardarse en un solo lugar y comprobarse sin base de datos, y cuando la misma regla se llama desde varios sitios: el cobro, el informe, la app móvil.
Más precisamente, para curiosos
Capas estrictas y relajadas. Con capas estrictas cada capa llama solo a su vecina. Con las relajadas (como en esta lección) puede saltar hacia abajo: el servicio llama directamente al repositorio. La mayoría de los proyectos son relajados. La regla estricta trae más traspaso de llamadas y rara vez ayuda.
En los frameworks. Spring pone las capas en anotaciones: @Controller, @Service, @Repository. ASP.NET Core tiene controladores, y los servicios y repositorios se registran en el contenedor de DI. Django se organiza por función: cada «app» tiene su propio views.py y models.py. Nest (Node) agrupa un controlador y sus servicios en un módulo por función, y Express no impone nada. Ningún framework vigila la dirección por sí solo: un @Repository que llama a un @Service compila.
Vigilar la dirección. La dirección se puede comprobar automáticamente: ArchUnit en Java, ArchUnitNET o NetArchTest en C#, import-linter en Python y dependency-cruiser en JavaScript fallan cuando un paquete de datos importa un paquete de dominio. Una pequeña comprobación de este tipo para módulos la escribes en la lección Monolito, monolito modular, servicios.
Dinero. En la lección las sumas son euros enteros, para que la salida sea igual en los cuatro lenguajes. En una tienda real el dinero va en centavos (long) o en Decimal, nunca en double.
Qué viene después. En la lección Arquitectura hexagonal y clean architecture la dirección se da vuelta por completo: el dominio no depende ni siquiera del repositorio.
Ejercicio
El envío en el dominio
Resuélvelo en tu editor.
El envío cuesta 5 € en un pedido de menos de 50 € y es gratis desde 50 € (la suma es después del descuento). Pon la regla en el dominio, en Delivery.fee(total). Un controlador nuevo muestra el precio y el envío, y el servicio y el repositorio no se tocan. Al final comprueba la regla por separado, sin controlador, para 49 € y 50 €.
María paga 90 € + envío 0 €Pedro paga 80 € + envío 0 €Jorge paga 30 € + envío 5 €49 € → envío 5 €50 € → envío 0 €Más tareas
- La app móvil. Añade un segundo controlador que devuelva la misma suma como texto JSON
{"customer":"María","total":90}. ¿Cuántas líneas de la regla tocas? - Descuento por cliente. Los clientes con tarjeta de fidelidad reciben un 15 % desde 100 €. ¿Dónde va la tarjeta: en el repositorio, en el dominio o en el controlador? ¿Por qué?
- Comprobar la dirección. Escribe una función que reciba una lista de «qué capa llama a cuál» (
data → domain,application → data) e imprima cada llamada que va hacia arriba.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Tests con unittest · Singleton e inyección de dependencias · Repository · SOLID sin dogma
- Continúa en: Arquitectura hexagonal y clean architecture · Monolito, monolito modular, servicios · Caché · ADR: registros de decisiones de arquitectura
- Ejercicios sobre el tema: Arquitectura de software