🍃 Buenas Prácticas Spring Boot
Guía de arquitectura limpia para APIs REST con Spring Boot, Kotlin y Gradle.
📋 Contexto
Desarrollar APIs mantenibles requiere separación clara de responsabilidades. Esta guía establece patrones probados para evitar acoplamiento entre capas y garantizar escalabilidad.
🎯 Objetivo
Proporcionar una estructura estándar para desarrollar APIs REST con:
- Separación Controller → Service → Repository
- DTOs para contratos de API
- Entidades JPA optimizadas
- Manejo consistente de errores
📦 Alcance
Incluye:
- Estructura de proyecto por dominio
- Entidades JPA sin
data class - DTOs y mappers
- Controladores delgados
- Relaciones JPA (@OneToMany, @ManyToOne, @OneToOne)
- PATCH parcial
- Manejo global de excepciones
Excluye:
- Configuración de seguridad avanzada
- Testing (cubierto en documento aparte)
🏛️ Arquitectura
Estructura por Dominio
com.example.app/
├── customer/
│ ├── controller/
│ ├── dto/
│ ├── entity/
│ ├── mapper/
│ ├── repository/
│ └── service/
├── order/
├── profile/
└── config/
Capas y Responsabilidades
| Capa | Responsabilidad | Qué NO hacer |
|---|---|---|
| Controller | Recibir request, validar input, delegar, transformar respuesta | Lógica de negocio, queries directos |
| Service | Reglas de negocio, orquestación, transacciones | Acceso HTTP, detalles de persistencia |
| Repository | Acceso a datos, queries | Lógica de negocio |
| Entity | Modelo persistente | Exponer directamente por HTTP |
🔧 Stack y Dependencias
build.gradle.kts
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
kotlin("plugin.jpa") version "1.9.25"
id("org.springframework.boot") version "3.3.4"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
runtimeOnly("org.postgresql:postgresql")
testImplementation("io.mockk:mockk:1.13.12")
}
🗄️ Modelos
Entidades (NO usar data class)
@Entity
@Table(name = "customers")
class Customer(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(nullable = false, length = 150)
var name: String,
@Column(nullable = false, unique = true, length = 150)
var email: String,
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
private val _orders: MutableList<Order> = mutableListOf(),
@OneToOne(mappedBy = "customer", fetch = FetchType.LAZY)
var profile: Profile? = null
) {
val orders: List<Order>
get() = Collections.unmodifiableList(_orders)
fun addOrder(order: Order) {
if (!_orders.contains(order)) {
_orders.add(order)
}
order.customer = this
}
}
Relaciones JPA
| Tipo | Lado Dueño | Uso recomendado |
|---|---|---|
| @ManyToOne | Siempre | FK real en tabla hija, LAZY por defecto |
| @OneToMany | Inverso (mappedBy) | Colección expuesta como List inmutable |
| @OneToOne | Dueño = lado con FK | Solo si justifica tabla separada |
DTOs
// Request
class CreateCustomerRequest(
@field:NotBlank @field:Size(max = 150)
val name: String,
@field:NotBlank @field:Email @field:Size(max = 150)
val email: String
)
// Para PATCH - todos opcionales
class PatchCustomerRequest(
@field:Size(max = 150)
val name: String? = null,
@field:Email @field:Size(max = 150)
val email: String? = null
)
// Response
class CustomerResponse(
val id: Long,
val name: String,
val email: String
)
🔌 API Endpoints
@RestController
@RequestMapping("/api/customers")
class CustomerController(
private val customerService: CustomerService,
private val customerMapper: CustomerMapper
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateCustomerRequest): CustomerResponse {
val customer = customerService.create(request)
return customerMapper.toResponse(customer)
}
@PatchMapping("/{id}")
fun patch(
@PathVariable id: Long,
@Valid @RequestBody request: PatchCustomerRequest
): CustomerResponse {
val customer = customerService.patch(id, request)
return customerMapper.toResponse(customer)
}
@GetMapping("/{id}")
fun getById(@PathVariable id: Long): CustomerDetailResponse {
val customer = customerService.getById(id)
return customerMapper.toDetailResponse(customer)
}
}
PUT vs PATCH
| Método | Uso | Ejemplo |
|---|---|---|
| PUT | Reemplazo completo del recurso | { "name": "Nuevo", "email": "[email protected]" } |
| PATCH | Actualización parcial | { "name": "Nuevo" } |
Evitar PATCH para: Acciones de negocio complejas (cancelar orden, activar usuario). Usar endpoints explícitos:
POST /orders/{id}/cancel
⚙️ Servicios
@Service
class CustomerService(private val customerRepository: CustomerRepository) {
@Transactional
fun create(request: CreateCustomerRequest): Customer {
if (customerRepository.existsByEmail(request.email)) {
throw BusinessException("El email ya está registrado")
}
val customer = Customer(
name = request.name.trim(),
email = request.email.trim().lowercase()
)
return customerRepository.save(customer)
}
@Transactional
fun patch(id: Long, request: PatchCustomerRequest): Customer {
val customer = customerRepository.findById(id)
.orElseThrow { ResourceNotFoundException("Cliente no encontrado") }
request.name?.let {
val sanitized = it.trim()
if (sanitized.isBlank()) throw BusinessException("El nombre no puede quedar vacío")
customer.name = sanitized
}
request.email?.let {
val sanitized = it.trim().lowercase()
val existing = customerRepository.findByEmail(sanitized)
if (existing.isPresent && existing.get().id != customer.id) {
throw BusinessException("El email ya está registrado")
}
customer.email = sanitized
}
return customerRepository.save(customer)
}
}
❌ Manejo de Errores
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException::class)
fun handleNotFound(ex: ResourceNotFoundException, request: HttpServletRequest):
ResponseEntity<ApiErrorResponse> {
val response = ApiErrorResponse(
timestamp = OffsetDateTime.now(),
status = HttpStatus.NOT_FOUND.value(),
error = "Not Found",
message = ex.message ?: "Recurso no encontrado",
path = request.requestURI
)
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response)
}
@ExceptionHandler(BusinessException::class)
fun handleBusiness(ex: BusinessException, request: HttpServletRequest):
ResponseEntity<ApiErrorResponse> {
val response = ApiErrorResponse(
timestamp = OffsetDateTime.now(),
status = HttpStatus.BAD_REQUEST.value(),
error = "Bad Request",
message = ex.message ?: "Error de negocio",
path = request.requestURI
)
return ResponseEntity.badRequest().body(response)
}
}
✅ Checklist de Calidad
- Controlador delgado (solo delegación)
- Entidades JPA sin
data class - Relaciones
LAZYpor defecto - DTOs separados para create/update/patch/response
-
@Transactionalen servicios - Excepciones personalizadas con handler global
- Validaciones en DTOs con
@Valid - Helper methods para sincronizar relaciones bidireccionales
- Uso de
@EntityGraphofetch joinpara lecturas complejas
🎯 Patrones Aplicados
| Patrón | Uso |
|---|---|
| DTO | Separar contrato API del modelo interno |
| Repository | Abstraer acceso a persistencia |
| Service Layer | Centralizar lógica de negocio |
| Mapper | Transformar entidades ↔ DTOs |