Una tienda necesita mostrar cuánto cuestan varias unidades de un producto. En la primera pantalla juntamos la búsqueda del producto, el cálculo y el texto que verá el cliente. Funciona. Después el panel de administración necesita el mismo presupuesto y copiamos el bloque entero. Ahora cualquier cambio en el cálculo obliga a acordarse de las dos pantallas. La calculadora acaba de abrir una sucursal.
La arquitectura en capas propone separar el código según su responsabilidad. Para entenderla vamos a seguir una operación pequeña de principio a fin: buscar un producto, calcular el precio de varias unidades y mostrar el resultado.
El trabajo que hay dentro de una petición
Pensemos en la pregunta «¿cuánto cuestan tres unidades del producto 1?». Parece una sola tarea, pero contiene decisiones distintas:
- Presentación: recibir la petición y decidir cómo mostrar la respuesta.
- Aplicación: organizar los pasos necesarios para obtener el presupuesto.
- Dominio: expresar las reglas del problema, como calcular un total para una cantidad válida.
- Acceso a datos: encontrar el producto solicitado entre los productos disponibles.
Aquí llamamos dominio al conocimiento del problema que resuelve el programa. El precio de un producto y el cálculo de su total pertenecen a ese conocimiento. El color del botón o la forma de almacenar los productos pertenecen a otras responsabilidades.
La capa de aplicación contiene el caso de uso: la operación que alguien quiere realizar. En este artículo, obtener un presupuesto. Buscar y calcular forman parte de su recorrido; elegir la frase que aparece en pantalla queda fuera de él.
Este reparto concreta la cohesión y la separación de responsabilidades: reunimos lo que tiene un propósito y hacemos visibles sus colaboraciones. Veamos qué significa en código.
El producto conoce su precio
Nuestros productos tienen un identificador y un precio por unidad. Usaremos precios enteros de 20 y 30 euros para que el cálculo se pueda seguir de un vistazo.
class Product {
private int id;
private int unitPriceInEuros;
public Product(int id, int unitPriceInEuros) {
this.id = id;
this.unitPriceInEuros = unitPriceInEuros;
}
public int getId() {
return id;
}
public int calculateTotal(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive.");
}
return unitPriceInEuros * quantity;
}
}
El constructor recibe los datos del producto y los guarda. getId() permite consultar su identificador. calculateTotal() aplica una regla sencilla: para presupuestar hace falta una cantidad mayor que cero, y el total es el precio por unidad multiplicado por esa cantidad.
Por ejemplo, un producto de 20 euros devuelve 60 al pedir tres unidades. Si pedimos cero, el método informa de una cantidad inválida mediante una excepción. Una excepción interrumpe ese recorrido y permite que quien lo inició gestione el error.
El producto puede hacer este cálculo sin saber si lo llamó una pantalla, un proceso de administración u otra parte del programa. Esa independencia es útil: todos pasan por la misma regla. El ejemplo se limita al precio de los productos; el envío o los descuentos serían otras decisiones que habría que modelar si la operación los necesitase.
El catálogo busca de verdad
Para encontrar el producto usaremos una colección. List<Product> significa «una lista de productos». El catálogo recibe esa lista y la recorre buscando un identificador coincidente:
class Catalog {
private List<Product> products;
public Catalog(List<Product> products) {
this.products = products;
}
public Product findById(int productId) {
for (Product product : products) {
if (product.getId() == productId) {
return product;
}
}
throw new IllegalArgumentException("Product not found.");
}
}
El for examina los productos uno a uno. Cuando encuentra el solicitado, lo devuelve. Si termina el recorrido sin encontrarlo, informa del error. Funciona con cualquier producto que haya en la lista; no fabrica uno en función de un identificador escrito dentro del método.
La lista es el origen de datos de este ejemplo. Si más adelante los productos estuvieran en una base de datos, la búsqueda tendría que consultar ese almacenamiento. La responsabilidad seguiría siendo la misma: obtener el producto que corresponde al identificador recibido.
El servicio organiza la operación
La aplicación necesita coordinar dos pasos. Primero obtiene el producto; después le pide el total:
class QuoteService {
private Catalog catalog;
public QuoteService(Catalog catalog) {
this.catalog = catalog;
}
public int calculateTotal(int productId, int quantity) {
Product product = catalog.findById(productId);
return product.calculateTotal(quantity);
}
}
QuoteService recibe su catálogo en el constructor. Así podemos ver qué necesita para trabajar. Su método se lee prácticamente en el mismo orden que la explicación: buscar el producto y calcular el precio de la cantidad solicitada.
La regla del total permanece en Product. El servicio no la copia, y tampoco prepara el texto de la respuesta. Este es el tipo de reparto que ayuda a aplicar el principio de responsabilidad única: cambiar una regla de precio y cambiar una frase de la interfaz tienen motivos diferentes.
La presentación prepara la respuesta
Representaremos esa última responsabilidad con un método que devuelve el texto que queremos mostrar:
class QuoteController {
private QuoteService quoteService;
public QuoteController(QuoteService quoteService) {
this.quoteService = quoteService;
}
public String showQuote(int productId, int quantity) {
int total = quoteService.calculateTotal(productId, quantity);
return "Total: " + total + " EUR";
}
}
Aquí se decide cómo expresar el resultado. Podríamos cambiar la frase o preparar una respuesta para una web sin modificar la multiplicación. En una entrada real, esta parte también interpretaría los datos recibidos y decidiría cómo presentar los errores.
La distinción importa: recibir una cantidad escrita como «tres» plantea un problema de formato; recibir el número cero plantea el rechazo que hemos definido en el producto. Son preguntas diferentes aunque aparezcan durante la misma petición.
Sigamos dos productos distintos
Creamos la lista y conectamos los objetos. add() introduce un elemento en la colección:
List<Product> products = new ArrayList<>();
products.add(new Product(1, 20));
products.add(new Product(2, 30));
Catalog catalog = new Catalog(products);
QuoteService service = new QuoteService(catalog);
QuoteController controller = new QuoteController(service);
controller.showQuote(1, 3); // "Total: 60 EUR"
controller.showQuote(2, 2); // "Total: 60 EUR"
En la primera llamada, el catálogo encuentra el producto 1, cuyo precio es 20. El producto calcula 20 × 3 y el controlador prepara el texto. En la segunda, la búsqueda selecciona otro producto y el cálculo pasa a ser 30 × 2. Coincide el total, pero los datos utilizados son distintos.
Si pedimos el producto 99, la búsqueda falla y el cálculo no llega a realizarse. Si pedimos cero unidades de un producto existente, la búsqueda funciona y es el producto quien rechaza la cantidad. Podemos localizar cada comportamiento en la pieza que lo decide.

Las flechas del diagrama significan «utiliza este tipo en su código». El recorrido de una petición se puede leer aparte: controlador, servicio, búsqueda, cálculo y respuesta. Una dependencia de código describe conocimiento; un recorrido describe el orden en que ocurren las llamadas.
Qué aportan las capas y dónde está su límite
La separación entre presentación, dominio y datos que explica Martin Fowler permite concentrarse en una responsabilidad cada vez. Nuestro ejemplo distingue además la coordinación del caso de uso de la regla del producto.
Eso no convierte cuatro responsabilidades en cuatro servidores. Son separaciones lógicas y pueden vivir dentro de la misma aplicación. Tampoco obliga a introducir intermediarios que se limitan a reenviar llamadas: KISS y YAGNI siguen siendo buenas preguntas al añadir estructura.
Hay otra limitación visible: QuoteService conoce la clase concreta Catalog. Si queremos que el caso de uso dependa de un contrato de búsqueda en lugar de una implementación, tendremos que diseñar esa frontera. Los puertos de la arquitectura hexagonal ayudan a entender esa decisión.
Las capas tampoco fijan el primer nivel de carpetas. Los cortes verticales permiten reunir las piezas de una funcionalidad y decidir su organización interna. Clasificar por responsabilidad y agrupar por funcionalidad son decisiones que pueden colaborar.
Una revisión práctica consiste en cambiar algo pequeño: la frase de salida debería llevarnos al controlador; la regla del total, al producto; la búsqueda, al catálogo. Si sabemos explicar por qué toca cambiar cada pieza, la separación ya está haciendo un trabajo útil.