Ir al contenido

Tutorial: El Service de una entidad, con sus reglas de negocio

Al terminar este tutorial vas a ser capaz de:

  • Explicar la cadena Controller → Service → Repository y qué le toca a cada capa.
  • Crear las excepciones EntityNotFoundException (404) e IllegalOperationException (400).
  • Escribir un <Entidad>Service con inyección por constructor y los cinco métodos findAll, findById, create, update y delete.
  • Implementar en create tres reglas de negocio de tu tabla de la wiki, una de ellas sobre una fecha (LocalDate).
  • Dejar el texto de los mensajes de error en una clase aparte, no escrito en cada throw.
  • Reutilizar findById en update y delete para obtener el 404 sin repetir código.
  • Validar en update las mismas reglas que en create, con el cuidado de que la unicidad ignore al propio registro.

Prerrequisitos: haber terminado los tutoriales de Release 6, en particular Convertir tu repository a JpaRepository, y haber hecho el Taller de reglas de negocio: vas a necesitar tu tabla de reglas. También conviene haber leído la teoría de Lógica de negocio.


Hoy tu ArtistaController inyecta directamente el ArtistaRepository. A partir de Release 7, la cadena es:

Controller → Service → Repository
  • El Controller recibe la petición HTTP y devuelve la respuesta.
  • El Service es donde viven las reglas de negocio: valida antes de guardar, modificar o borrar.
  • El Repository habla con la base de datos.

Dos decisiones de este curso que conviene tener presentes:

  • El Service trabaja con entidades, no con DTOs: recibe y devuelve ArtistaEntity. Convertir entre entidad y DTO es trabajo del Controller, y es el tema del siguiente tutorial.
  • Este tutorial no toca el Controller. El Service se prueba por sí solo, con JUnit y sin arrancar la aplicación, en el tutorial de pruebas del Service. La prueba por HTTP llega cuando conectes el Controller.

El ejemplo usa ArtistaService y tres reglas del taller: el nombre del artista es obligatorio, no puede haber dos artistas con el mismo nombre y, si se indica la fecha de nacimiento, el artista debe ser mayor de edad. Cuando lo tengas, repites el mismo patrón para las demás entidades de tu módulo con tus reglas.


Cuando una regla falla, el Service lanza una excepción, y el Controller la traduce a un código HTTP (eso lo haces en el siguiente tutorial). Son dos, y no son intercambiables (el taller explica cuál va en cada caso):

Excepción Cuándo se lanza Código
EntityNotFoundException El id que se busca no existe 404
IllegalOperationException La operación viola una regla de negocio 400

Estas dos clases son del grupo, no de tu módulo: van en un paquete propio, co.edu.uniandes.musical.exception, al lado de artistas. Recuerda que musical es el nombre del proyecto de ejemplo: usa el que tu grupo acordó, el mismo de tu paquete base. Si cada integrante crea las suyas, el grupo termina con varias EntityNotFoundException distintas, y el manejador de excepciones del siguiente tutorial, que atiende una sola, no devolvería el código esperado para las demás. Un integrante las crea una sola vez, en un PR pequeño, y los demás las traen con git pull. Si exception/EntityNotFoundException.java ya existe en el repositorio de tu grupo, no las crees: solo impórtalas.

Crea una carpeta nueva exception/ dentro de musical/, al lado de artistas/, y en ella EntityNotFoundException.java:

package co.edu.uniandes.musical.exception;
/** El recurso que se busca por su id no existe. Se responde con 404. */
public class EntityNotFoundException extends Exception {
public EntityNotFoundException(String message) {
super(message);
}
}

Y IllegalOperationException.java, en la misma carpeta:

package co.edu.uniandes.musical.exception;
/** La operación viola una regla de negocio. Se responde con 400. */
public class IllegalOperationException extends Exception {
public IllegalOperationException(String message) {
super(message);
}
}

Fíjate en las piezas:

  • Ninguna anotación de HTTP. El Service no conoce HTTP: solo lanza la excepción. Quien la convierte en un 404 o un 400, con el mensaje en el cuerpo de la respuesta, es un manejador de excepciones del Controller, que escribes en el siguiente tutorial.
  • extends Exception hace que sean excepciones comprobadas: cada método que pueda lanzarlas debe declararlo con throws. Es la convención de la teoría, y obliga a quien llama al Service a pensar qué hacer en cada caso.

Cada excepción lleva un mensaje, y ese texto no se escribe dentro del throw: vive en una clase aparte, MensajesEs, en el mismo paquete exception. Es otra clase del grupo, con una sola copia, como las excepciones. Crea MensajesEs.java en la carpeta exception/:

package co.edu.uniandes.musical.exception;
/** Mensajes de error en español. */
public final class MensajesEs {
private MensajesEs() {
}
// Artista
public static final String ARTISTA_NO_EXISTE = "El artista no existe";
public static final String ARTISTA_NOMBRE_OBLIGATORIO = "El nombre del artista es obligatorio";
public static final String ARTISTA_NOMBRE_DUPLICADO = "Ya existe un artista con ese nombre";
/** Parámetro: la edad mínima, como número (%d). */
public static final String ARTISTA_MENOR_DE_EDAD = "El artista debe ser mayor de edad (al menos %d años)";
}

Por qué separarlos es una buena práctica:

  • El idioma puede cambiar. Hoy los mensajes están en español, pero una aplicación real puede tener que responder en otro idioma. Con los textos en una clase, agregar el inglés es crear una clase hermana (MensajesEn) con las mismas constantes, en vez de recorrer todos los Services buscando frases entre comillas. Para atender varios idiomas a la vez Spring ofrece MessageSource, que va más allá de este curso: lo que importa es el hábito de separar.
  • Cada mensaje se define una sola vez. El mismo “El artista no existe” lo lanzan varios Services (el de artistas y el de asociaciones). Con el texto suelto en cada throw, corregir una palabra obliga a buscar todas las copias, y es fácil que dos digan casi lo mismo con palabras distintas.
  • El Service se lee mejor. MensajesEs.ARTISTA_NOMBRE_DUPLICADO dice qué regla falló sin leer la frase completa.

Las constantes se nombran con el prefijo de la entidad (ARTISTA_...). Los mensajes con datos variables llevan un marcador: %d para un número y %s para un texto, que se rellena con .formatted(...), como verás en la regla de la edad. Como el archivo es compartido, cada integrante agrega sus constantes en su propia sección (la de su entidad) y sube ese cambio en un PR pequeño, para que no choquen.


La regla “no puede haber dos artistas con el mismo nombre” necesita consultar la base de datos por nombre. Agrega un método a ArtistaRepository.java:

package co.edu.uniandes.musical.artistas;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArtistaRepository extends JpaRepository<ArtistaEntity, Long> {
List<ArtistaEntity> findByNombre(String nombre);
}

No escribes ninguna implementación: Spring Data JPA genera la consulta a partir del nombre del método (findBy + el atributo Nombre). Es una consulta derivada; la teoría de Lógica de negocio muestra más ejemplos. Devuelve una lista porque, si la regla no existiera, podría haber varios artistas con el mismo nombre.


Crea ArtistaService.java. Empieza con la estructura y los dos métodos de lectura:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.MensajesEs;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ArtistaService {
private final ArtistaRepository artistaRepository;
public ArtistaService(ArtistaRepository artistaRepository) {
this.artistaRepository = artistaRepository;
}
@Transactional(readOnly = true)
public List<ArtistaEntity> findAll() {
return artistaRepository.findAll();
}
@Transactional(readOnly = true)
public ArtistaEntity findById(Long id) throws EntityNotFoundException {
Optional<ArtistaEntity> artista = artistaRepository.findById(id);
if (artista.isEmpty()) {
throw new EntityNotFoundException(MensajesEs.ARTISTA_NO_EXISTE);
}
return artista.get();
}
}

Fíjate en las piezas:

  • @Service marca la clase como un componente de Spring, igual que @RestController o @Repository, para que se pueda inyectar.
  • El repository entra por el constructor, no con @Autowired sobre el atributo. Es la misma forma que ya usas en el Controller. El atributo es private final: una vez recibido, no cambia.
  • @Transactional hace que cada método se ejecute dentro de una transacción. En las lecturas se marca readOnly = true para indicar que no modifican datos.
  • findById convierte un Optional vacío en un 404. Es el único lugar donde se decide qué significa “no existe”.

Agrega estos dos imports junto a los demás, el de la segunda excepción y el del tipo de la fecha:

import co.edu.uniandes.musical.exception.IllegalOperationException;
import java.time.LocalDate;

Agrega también, al inicio de la clase y antes del repository, la edad mínima como constante:

private static final int MAYORIA_EDAD = 18;

Y agrega create dentro de la clase, después de findById:

Y el método:

@Transactional
public ArtistaEntity create(ArtistaEntity artista) throws IllegalOperationException {
validarNombre(artista);
validarMayoriaDeEdad(artista);
List<ArtistaEntity> conElMismoNombre = artistaRepository.findByNombre(artista.getNombre());
if (!conElMismoNombre.isEmpty()) {
throw new IllegalOperationException(MensajesEs.ARTISTA_NOMBRE_DUPLICADO);
}
// Un create siempre crea: se descarta cualquier id que llegue
artista.setId(null);
return artistaRepository.save(artista);
}

Y, al final de la clase, el método privado que comparten create y update:

private void validarNombre(ArtistaEntity artista) throws IllegalOperationException {
if (artista.getNombre() == null || artista.getNombre().isBlank()) {
throw new IllegalOperationException(MensajesEs.ARTISTA_NOMBRE_OBLIGATORIO);
}
}
private void validarMayoriaDeEdad(ArtistaEntity artista) throws IllegalOperationException {
LocalDate nacimiento = artista.getFechaNacimiento();
if (nacimiento != null && nacimiento.isAfter(LocalDate.now().minusYears(MAYORIA_EDAD))) {
throw new IllegalOperationException(MensajesEs.ARTISTA_MENOR_DE_EDAD.formatted(MAYORIA_EDAD));
}
}

Cada regla sigue el mismo patrón, el que muestra la teoría de Reglas de negocio:

  1. Consultar lo que la regla necesita (acá, los artistas con ese nombre).
  2. Evaluar la condición.
  3. Lanzar la excepción y cancelar la operación si no se cumple.
  4. Persistir solo cuando todas las reglas pasaron.

Si cualquiera de las tres reglas falla, save nunca se ejecuta. Hay cinco detalles importantes:

  • El texto del mensaje no va en el throw. Cada throw toma su mensaje de MensajesEs, la clase de la sección 3: así el texto se define una sola vez y se puede cambiar, o traducir, sin tocar la lógica de la regla.

  • Primero las reglas baratas. Se valida el nombre obligatorio y la edad antes de consultar la base de datos: no tiene sentido buscar un nombre null, ni consultar nada si el artista ya es menor de edad.

  • La condición sale de tu tabla. El if de cada regla es la columna “condición que la viola” del taller. Las tres reglas de este ejemplo (nombre obligatorio, nombre único y edad mínima) son del tipo de las que el taller pide registrar en la ronda 1: obligatorio, unicidad y rango.

  • Una regla sobre una fecha. fechaNacimiento es un LocalDate, así que se compara con otras fechas, no con texto. LocalDate.now().minusYears(MAYORIA_EDAD) es la fecha de corte: el día en que nació quien cumple 18 años hoy. Si la fecha del artista es posterior (isAfter) al corte, todavía es menor. Quien cumple 18 años hoy pasa la regla: su fecha es igual al corte, no posterior. Si la fecha no viene (null), la regla no se aplica, porque la fecha de nacimiento es opcional. Esta regla no consulta la base de datos: se decide mirando solo el dato recibido. La edad es una constante porque aparece dos veces, en la condición y en el mensaje (.formatted(MAYORIA_EDAD)): si cambia, cambia en un solo lugar.

  • Un create siempre crea. artista.setId(null) descarta cualquier id que venga en lo que recibe el método. Sin esa línea, un cliente que envíe {"id": 1, ...} haría que save actualizara el artista 1 en vez de crear uno nuevo, y le borraría sus álbumes. Se comprobó con Spring Boot 4.1.1.


update y delete empiezan igual: comprueban que el artista exista. En vez de repetir la consulta, reutilizan findById, así el 404 es el mismo en los tres métodos. Pero update no termina ahí: los valores nuevos llegan en el cuerpo del PUT y pueden violar las mismas reglas que un create, así que update las valida también antes de guardar. Si solo comprobara el id, un PUT podría dejar un artista sin nombre, con el nombre de otro o menor de edad. Agrégalos a la clase:

@Transactional
public ArtistaEntity update(Long id, ArtistaEntity artista)
throws EntityNotFoundException, IllegalOperationException {
ArtistaEntity existente = findById(id);
validarNombre(artista);
validarMayoriaDeEdad(artista);
for (ArtistaEntity otro : artistaRepository.findByNombre(artista.getNombre())) {
if (!otro.getId().equals(id)) {
throw new IllegalOperationException(MensajesEs.ARTISTA_NOMBRE_DUPLICADO);
}
}
existente.setNombre(artista.getNombre());
existente.setFotoArtistaUrl(artista.getFotoArtistaUrl());
existente.setPaisOrigen(artista.getPaisOrigen());
existente.setBiografia(artista.getBiografia());
existente.setFechaNacimiento(artista.getFechaNacimiento());
return artistaRepository.save(existente);
}
@Transactional
public void delete(Long id) throws EntityNotFoundException {
ArtistaEntity artista = findById(id);
artistaRepository.delete(artista);
}

La regla de unicidad en update no es igual a la de create. Al actualizar un artista sin cambiarle el nombre, la consulta findByNombre lo encuentra a él mismo, y eso no debe ser un error. Por eso el if compara el id del artista encontrado con el que se está actualizando, y solo falla si es otro artista. Es un cuidado de alcance parecido al de la regla 5 de la teoría (el nombre único dentro de una compañía), y es un error muy frecuente: si copias el if de create tal cual, nadie puede guardar un artista sin cambiarle el nombre.

Fíjate en cómo termina update: copia los atributos básicos que llegan sobre el artista que ya existe (existente) y guarda ese. No guarda el objeto recibido. La diferencia importa:

  • update solo cambia lo que trae el objeto recibido: el nombre, la foto, el país, la biografía y la fecha de nacimiento. Si un atributo básico no viene, queda en null, porque un PUT reemplaza los datos del recurso.
  • Todo lo que no forma parte de lo que llega se queda exactamente igual: los álbumes del artista y sus géneros no se tocan.

Para comprobar el Service no hace falta el Controller ni arrancar la aplicación: se prueba con JUnit contra la base de datos real, en el tutorial de pruebas del Service. Allí cada regla de tu tabla de la wiki tiene un caso feliz y un caso en el que se viola.


Se puede usar IA para el esqueleto del Service: las anotaciones, la inyección por constructor, las firmas de los métodos y las dos excepciones. El prompt debe incluir el nombre de la entidad, el repository que inyecta y los métodos que necesitas.

Lo que no se delega es la condición de cada regla: sale de tu enunciado y la escribes tú. Un prompt razonable es: “dado este método con esta firma, agrega el manejo de excepción para cuando [condición que tú redactas]. Usa la clase IllegalOperationException que ya existe.” Después verifica que cada regla corresponda a una fila de tu tabla de la wiki y que los casos de error realmente fallan.


Al final de este tutorial tienes:

  • Las excepciones EntityNotFoundException (404) e IllegalOperationException (400).
  • Una clase MensajesEs con los textos de los errores, y un ArtistaService con inyección por constructor, los cinco métodos y tres reglas de negocio en create, una de ellas sobre una fecha, que lanza sus excepciones con esos mensajes.
  • update y delete reutilizando findById, y la unicidad de update distinta a la de create.
  • Un update que valida las mismas reglas que create.

Lo que viene después, siguiendo el orden de Release 7:

  • Conectar el Controller al Service, devolver DTOs en vez de entidades, y traducir las excepciones a respuestas HTTP.
  • Repetir el patrón con las demás entidades de tu módulo, con las reglas de tu tabla.
  • Pruebas automáticas del Service, con JUnit y la base de datos.