Ir al contenido
wannacode

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 mismo
class CheckoutController {
constructor(carts) {
this.carts = carts;
}
post(customer) {
let total = this.carts.get(customer);
if (total >= 100) total = total - Math.floor(total / 10);
return `${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.
function discounted(carts) {
return [...carts].filter(([, subtotal]) => subtotal > 100).map(([customer]) => customer);
}
const carts = new Map([["María", 100], ["Lucía", 150], ["Pedro", 80]]);
const checkout = new CheckoutController(carts);
for (const customer of carts.keys())
console.log("Cobro: " + checkout.post(customer));
console.log("Informe: con descuento hay " + discounted(carts).join(", "));
Salida
Cobro: María paga 90 €
Cobro: Lucía paga 135 €
Cobro: Pedro paga 80 €
Informe: con descuento hay Lucía

Marí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.total conoce «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 respuesta
class CheckoutController {
constructor(service) {
this.service = service;
}
post(customer) {
const total = this.service.checkout(customer);
return `${customer} paga ${total} €`;
}
}
// Aplicación: los pasos de una tarea
class CheckoutService {
constructor(carts) {
this.carts = carts;
}
checkout(customer) {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal);
}
}
// Dominio: la regla de la tienda y nada más
const Pricing = {
total(subtotal) {
if (subtotal < 100) return subtotal;
return subtotal - Math.floor(subtotal / 10);
},
};
// Datos: un repositorio de carritos tiene subtotalOf(customer)
JavaScript no tiene interfaces. El contrato del repositorio es el comentario del final: sirve cualquier objeto con subtotalOf(customer).

El repositorio en memoria y el informe, que ahora pregunta al dominio en lugar de escribir su propia condición:

class InMemoryCarts {
#subtotals = new Map();
put(customer, subtotal) {
this.#subtotals.set(customer, subtotal);
}
subtotalOf(customer) {
return this.#subtotals.get(customer) ?? 0;
}
}
// Aplicación: el informe pregunta al dominio, no tiene regla propia
class DiscountReport {
constructor(carts) {
this.carts = carts;
}
discounted(customers) {
return customers.filter((customer) => {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal) < subtotal;
});
}
}

Todo junto: el cobro y el informe sobre los mismos datos.

const carts = new InMemoryCarts();
carts.put("María", 100);
carts.put("Lucía", 150);
carts.put("Pedro", 80);
const customers = ["María", "Lucía", "Pedro"];
const web = new CheckoutController(new CheckoutService(carts));
for (const customer of customers)
console.log(web.post(customer));
const report = new DiscountReport(carts);
console.log("Con descuento: " + report.discounted(customers).join(", "));
Salida
María paga 90 €
Lucía paga 135 €
Pedro paga 80 €
Con descuento: María, Lucía

// Presentación: entra la solicitud, sale la respuesta
class CheckoutController {
constructor(service) {
this.service = service;
}
post(customer) {
const total = this.service.checkout(customer);
return `${customer} paga ${total} €`;
}
}
// Aplicación: los pasos de una tarea
class CheckoutService {
constructor(carts) {
this.carts = carts;
}
checkout(customer) {
const subtotal = this.carts.subtotalOf(customer);
return Pricing.total(subtotal);
}
}
// Dominio: la regla de la tienda y nada más
const Pricing = {
total(subtotal) {
if (subtotal < 100) return subtotal;
return subtotal - Math.floor(subtotal / 10);
},
};
// Datos: un repositorio de carritos tiene subtotalOf(customer)
Paso a paso, en palabras
  1. Cuatro capas: presentación, aplicación, dominio, datos. Cada una llama solo a las capas de abajo: todas las flechas apuntan hacia abajo.
  2. La solicitud de cobro de María entra en el controlador. Solo lee la solicitud y muestra la respuesta.
  3. El controlador pasa la tarea a la capa de aplicación: el servicio conoce los pasos del cobro.
  4. El servicio pregunta a la capa de datos por el carrito: 100 €. El repositorio da datos y no guarda reglas.
  5. Luego pregunta al dominio: Pricing conoce la regla «desde 100 € hay un 10 % de descuento». 100 € pasan a ser 90 €.
  6. La respuesta sube de nuevo al controlador, y este imprime la línea. El informe pregunta al mismo Pricing.
  7. Ahora alguien hace que el repositorio devuelva la suma después del descuento. La capa de datos llama a Pricing: una flecha hacia arriba.
  8. El carrito de Lucía: el repositorio da 135 €, y el servicio descuenta otro 10 %. Paga 122 € en lugar de 135 €.
  9. 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 node:test.

¿Qué imprime el programa?

for (const subtotal of [99, 100, 101])
console.log(`${subtotal} € → ${Pricing.total(subtotal)} €`);
Respuesta

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 {
#subtotals = new Map();
put(customer, subtotal) {
this.#subtotals.set(customer, subtotal);
}
subtotalOf(customer) {
const subtotal = this.#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:

const carts = new InMemoryCarts();
carts.put("María", 100);
carts.put("Lucía", 150);
carts.put("Pedro", 80);
const customers = ["María", "Lucía", "Pedro"];
const web = new CheckoutController(new CheckoutService(carts));
for (const customer of customers)
console.log(web.post(customer));
const report = new DiscountReport(carts);
console.log("Con descuento: " + report.discounted(customers).join(", "));
Salida
María paga 90 €
Lucía paga 122 €
Pedro paga 80 €
Con descuento: Lucía

Lucí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 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.

src/
web/
checkout-controller.js
catalog-controller.js
services/
checkout-service.js
catalog-service.js
domain/
pricing.js
product.js
data/
cart-repository.js
product-repository.js

La segunda es por función (vertical slice): una carpeta para cada parte de la tienda, con las capas dentro.

src/
checkout/
controller.js
service.js
pricing.js
cart-repository.js
catalog/
controller.js
service.js
product.js
product-repository.js

Por 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 centavos enteros (Number no tiene tipo 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 €.

Salida
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

1. ¿En qué capa vive la regla «desde 100 € hay un 10 % de descuento»?
2. El servicio llama al repositorio directamente, sin pasar por el dominio. ¿Es una infracción?
3. El repositorio empieza a llamar a Pricing «para dar la suma después del descuento». ¿Qué pasa?
4. Una herramienta interna tiene tres formularios que solo guardan en la base de datos, sin ninguna regla. ¿Valen la pena cuatro capas?
5. El código está organizado por función (checkout/, catalog/). ¿Qué ganas frente a organizarlo por técnica (web/, service/, data/)?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·