Controller → Service → Repository
Cada solicitud de valuación de vehículos atraviesa tres capas con tres responsabilidades distintas — y mezclarlas es como se pudre un código base.
Las aplicaciones Spring Boot se organizan convencionalmente en tres capas, y esa convención existe por una razón que va mucho más allá de 'así lo hacen los tutoriales'. Un Controller maneja HTTP: interpreta la solicitud entrante, valida su forma y serializa una respuesta. Un Service contiene la lógica de negocio: las reglas, la orquestación, las decisiones que hacen que tu aplicación sea tu aplicación y no un envoltorio CRUD genérico. Un Repository maneja el acceso a datos: sabe cómo hacer entrar y salir filas de una base de datos y nada más. Cada capa tiene exactamente una razón para cambiar, y ese es precisamente el punto — cuando cambia la regla de cómo se pondera un puntaje de Carfax frente a uno de Black Book, tocas el Service. Cuando además de una API REST expones un endpoint GraphQL, tocas el Controller. Cuando migras el tipo de una columna en Postgres, tocas el Repository. Tres cambios distintos, tres archivos distintos, sin daño colateral.
Imagina la forma de una sola solicitud en nuestro escenario: un cliente llama a GET /vehicles/{vin}/valuation. VehicleController la recibe, extrae la variable de ruta VIN y llama a VehicleValuationService.valuate(vin). El service es donde ocurre el trabajo real — puede revisar una caché en Redis, llamar a uno o varios de BlackBookClient, CarfaxClient y VisClient, pasar los resultados por un motor de reglas y decidir una oferta final. En el camino, le pide a VehicleRepository que cargue o guarde datos de pricing_rule en Postgres. El repository no sabe por qué se le pide esto, y el controller no sabe cómo se calculó la valuación — cada capa confía en que la de abajo hace su trabajo y solo expone lo que la capa de arriba necesita.
Lo que cruza cada frontera importa tanto como las capas en sí. Entre el mundo exterior y el Controller tienes DTOs (Data Transfer Objects) — formas simples como ValuationResponse que existen únicamente para describir el formato de una solicitud o respuesta en el cable, sin ningún comportamiento de negocio adjunto. Entre el Controller y el Service normalmente pasas modelos de dominio, o en servicios más simples los mismos DTOs, pero el Service nunca debería filtrar su estado interno de trabajo hacia arriba. Entre el Service y el Repository pasas entidades — clases anotadas para JPA que mapean directamente sobre tablas de la base de datos, como una entidad PricingRule que mapea sobre la tabla pricing_rule. Mantener estas tres formas separadas, incluso cuando un proyecto es joven y es tentador reutilizar una sola clase en todas partes, es lo que evita que renombrar una columna de base de datos se convierta en un cambio que rompe tu API para los clientes.
La regla de que un controller nunca debe llamar directamente a un repository no es burocracia — es el mecanismo que mantiene la lógica de negocio en un solo lugar. Si VehicleController llamara directamente a VehicleRepository.findByVin(vin) y devolviera la entidad tal cual, te habrías saltado toda la lógica de valuación: sin revisión de caché, sin llamadas a los proveedores de precios, sin las salvaguardas del motor de reglas. Peor aún, si un segundo controller (digamos, un endpoint interno de administración) necesitara la misma valuación, o duplicaría esa lógica o, más probablemente, alguien copiaría y pegaría la mitad y los dos endpoints se irían separando poco a poco. Enrutar todo a través de VehicleValuationService significa que existe exactamente un lugar donde se decide 'cuánto vale este vehículo', sin importar cuántos puntos de entrada terminen llamándolo.
Esta disciplina también tiene un beneficio directo en las pruebas que sentirás de inmediato en cuanto las escribas. Una prueba de Controller puede simular VehicleValuationService y verificar solo asuntos de HTTP: códigos de estado, forma del JSON, manejo de encabezados — sin base de datos, sin corrutinas, sin proveedores externos involucrados. Una prueba de Service puede simular VehicleRepository y las tres implementaciones de ValuationProviderClient y verificar solo reglas de negocio: si un registro de Carfax desactualizado provoca el respaldo correcto, si una salvaguarda de no-compra realmente bloquea una oferta. Una prueba de Repository, respaldada por Testcontainers con un Postgres real, verifica solo el acceso a datos: si la consulta devuelve lo que esperas, si la migración de Liquibase realmente produce el esquema que asume la entidad. Como las capas no conocen los detalles internas de las otras, cada clase de prueba se mantiene acotada y rápida, y un fallo te dice exactamente qué capa se rompió.
Ayuda ver el esqueleto antes de la carne. VehicleController está anotado @RestController y depende de VehicleValuationService a través de su constructor; sus métodos son delgados — extraen la entrada, delegan, envuelven el resultado. VehicleValuationService está anotado @Service y depende de VehicleRepository más los tres clientes de proveedores; sus métodos contienen la toma de decisiones real. VehicleRepository extiende el JpaRepository de Spring Data JPA y en su mayoría declara firmas de métodos de consulta en lugar de implementaciones — Spring genera el SQL por ti a partir del nombre del método o de una anotación @Query. Ninguna de estas tres clases construye sus propias dependencias; todas reciben sus colaboradores desde afuera, que es justamente el tema de la siguiente lección.
Una cosa más que vale la pena interiorizar ahora, porque volverá a aparecer: 'capa' describe una dirección del conocimiento, no un nombre de carpeta. Puedes perfectamente organizar tu código por funcionalidad (un paquete vehicle que contiene su propio controller, service y repository) en lugar de por capa técnica (un paquete controllers, uno services, uno repositories), y muchos códigos base de Spring Boot bien llevados hacen exactamente eso. Lo que nunca debe pasar, sin importar la organización de carpetas, es que una capa inferior mire hacia arriba — un Repository nunca debe conocer a un Service, y un Service nunca debe conocer códigos de estado HTTP. El conocimiento fluye en una sola dirección: Controller conoce a Service, Service conoce a Repository y a los clientes de proveedores, y nada conoce lo que está por encima.
@RestController@RequestMapping("/vehicles")class VehicleController(private val valuationService: VehicleValuationService,) {@GetMapping("/{vin}/valuation")suspend fun getValuation(@PathVariable vin: String): ResponseEntity<ValuationResponse> {val result = valuationService.valuate(vin)return ResponseEntity.ok(result.toResponse())}}data class ValuationResponse(val vin: String,val offerCents: Long,val decision: String,)
The Controller layer: thin, HTTP-only, delegating everything to the Service. Requires a real Spring Boot context to run, so it does not run in-browser.
@Serviceclass VehicleValuationService(private val repository: VehicleRepository,private val blackBook: BlackBookClient,private val carfax: CarfaxClient,private val vis: VisClient,) {suspend fun valuate(vin: String): Valuation {val rules = repository.findActiveRulesFor(vin)val quotes = listOf(blackBook.quote(vin),carfax.quote(vin),vis.quote(vin),)return applyRulesEngine(rules, quotes)}}
The Service layer: business logic and orchestration, with no knowledge of HTTP. Requires the provider clients and repository to be wired by Spring, so it does not run in-browser.
interface VehicleRepository : JpaRepository<PricingRule, Long> {@Query("SELECT r FROM PricingRule r WHERE r.vinPrefix = :vinPrefix AND r.active = true")suspend fun findActiveRulesFor(vinPrefix: String): List<PricingRule>}@Entity@Table(name = "pricing_rule")class PricingRule(@Id @GeneratedValue val id: Long? = null,val vinPrefix: String,val active: Boolean,)
The Repository layer: a Spring Data JPA interface with no implementation body — Spring generates the query. Requires a real Postgres connection, so it does not run in-browser.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. VehicleController needs to expose a new internal endpoint that returns raw pricing_rule rows for an admin dashboard, unmodified by any business logic. What is the correct way to do this?