Saltar al contenido principal

🍃 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​

CapaResponsabilidadQué NO hacer
ControllerRecibir request, validar input, delegar, transformar respuestaLógica de negocio, queries directos
ServiceReglas de negocio, orquestación, transaccionesAcceso HTTP, detalles de persistencia
RepositoryAcceso a datos, queriesLógica de negocio
EntityModelo persistenteExponer 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​

TipoLado DueñoUso recomendado
@ManyToOneSiempreFK real en tabla hija, LAZY por defecto
@OneToManyInverso (mappedBy)Colección expuesta como List inmutable
@OneToOneDueño = lado con FKSolo 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étodoUsoEjemplo
PUTReemplazo completo del recurso{ "name": "Nuevo", "email": "[email protected]" }
PATCHActualizació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 LAZY por defecto
  • DTOs separados para create/update/patch/response
  • @Transactional en servicios
  • Excepciones personalizadas con handler global
  • Validaciones en DTOs con @Valid
  • Helper methods para sincronizar relaciones bidireccionales
  • Uso de @EntityGraph o fetch join para lecturas complejas

🎯 Patrones Aplicados​

PatrónUso
DTOSeparar contrato API del modelo interno
RepositoryAbstraer acceso a persistencia
Service LayerCentralizar lógica de negocio
MapperTransformar entidades ↔ DTOs

📚 Referencias​