Ir al contenido

Lógica de negocio

API, @RestController

Trabaja con DTO y JSON. Recibe peticiones y no accede directamente a la base de datos.

Lógica, @Service

Valida reglas e invoca persistencia. Trabaja exclusivamente con entidades, sin DTO, JSON ni HTTP.

Persistencia, JpaRepository

Expone CRUD y consultas personalizadas; Spring genera la implementación.

Sin esta capa, cada regla de negocio quedaría escrita dentro del controller, mezclada con el manejo de HTTP, o no se validaría antes de llegar al repository. El service concentra esas reglas en un solo lugar: no importa si la petición llega por un endpoint, por un job programado o por otro service, la validación es la misma porque vive en un solo método.

@Service registra la clase como bean de Spring. Los nombres habituales son BookService, AuthorService o EditorialService.

La capa de lógica recibe y retorna objetos @Entity; los controllers realizan la conversión entre Entity y DTO. Sus responsabilidades son validar antes de persistir, coordinar API y persistencia, comunicarse con servicios externos y lanzar excepciones cuando una regla no se cumple.

El service necesita el repository para consultar y persistir. Se inyecta por constructor, no con @Autowired sobre un campo:

@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
}

Con un solo constructor, Spring lo detecta automáticamente y le pasa la implementación de BookRepository: no hace falta anotarlo con @Autowired. El campo puede ser final porque su valor no vuelve a cambiar después de construir el objeto, y una prueba unitaria puede pasarle un mock al constructor sin necesidad de levantar el contexto de Spring.

Inyección de dependencia del controller al service

Sección titulada «Inyección de dependencia del controller al service»

El controller ya no recibe el repository, recibe el service. Mismo patrón de constructor:

@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
}

El flujo es Controller → Service → Repository: el controller no sabe cómo se valida una regla, y el repository no sabe que existe HTTP. Esto desacopla las capas y facilita las pruebas y el mantenimiento.

La capa de lógica valida las condiciones que deben cumplirse antes de persistir o modificar datos. Aunque cada regla consulta información diferente, todas siguen el mismo patrón:

  1. Consultar los datos existentes que necesita la regla.
  2. Evaluar la condición de negocio.
  3. Lanzar una excepción y cancelar la operación si la condición no se cumple.
  4. Persistir solamente cuando todas las validaciones terminan correctamente.

Simula una validación de negocio

Las reglas cambian, pero todas recorren el patrón consultar, evaluar, detener o persistir.

Entrada al servicioCompanyEntity entity name = entity.getName()
Estado previo de la BDfindByName(name) → [CompanyEntity] (ya existe)
Resultado esperadoIllegalOperationException repository.save() NO se ejecuta
RepositoryPaso 1 de 4
Consultar por nombre

Se busca si ya existe una empresa con el nombre recibido.

repository.findByName(entity.getName())

1. No puede haber dos compañías con el mismo nombre

Sección titulada «1. No puede haber dos compañías con el mismo nombre»

Antes de crear o actualizar una compañía, el service consulta la base de datos para verificar si ya existe otra con el mismo nombre.

@Transactional
public CompanyEntity createCompany(CompanyEntity entity)
throws IllegalOperationException {
// 1. Consultar la persistencia
List<CompanyEntity> alreadyExist =
repository.findByName(entity.getName());
// 2. Validar la regla de negocio
if (!alreadyExist.isEmpty()) {
throw new IllegalOperationException(
"Ya existe una compañía con ese nombre"
);
}
// 3. Si pasa, persistir
return repository.save(entity);
}
  1. El service recibe una CompanyEntity.
  2. findByName consulta las compañías que ya tienen ese nombre.
  3. Si la lista no está vacía, se lanza IllegalOperationException.
  4. Si la lista está vacía, se ejecuta repository.save(entity).

Antes de crear un libro se verifica que el ISBN no sea null ni una cadena vacía. Esta regla valida directamente un atributo de la entidad y no requiere consultar la base de datos.

@Transactional
public BookEntity createBook(BookEntity book)
throws IllegalOperationException {
// Validar que el ISBN no sea vacío
if (book.getIsbn() == null
|| book.getIsbn().isEmpty()) {
throw new IllegalOperationException(
"El ISBN no puede ser vacío"
);
}
return repository.save(book);
}
  1. El service recibe una BookEntity.
  2. Comprueba si isbn es null o está vacío.
  3. Si alguna condición se cumple, lanza la excepción y no guarda el libro.
  4. Si el ISBN tiene contenido, ejecuta repository.save(book).

3. La publicación debe ser posterior al nacimiento del autor

Sección titulada «3. La publicación debe ser posterior al nacimiento del autor»

Esta regla compara datos de dos entidades relacionadas: el libro y su autor.

// Validar coherencia de fechas
if (book.getPublishDate() != null
& author.getBirthDate() != null) {
if (book.getPublishDate()
.before(author.getBirthDate())) {
throw new IllegalOperationException(
"La fecha de publicación no puede "
+ "ser anterior al nacimiento del autor"
);
}
}
  1. Se obtienen la fecha de publicación del libro y la fecha de nacimiento del autor.
  2. La comparación solo se realiza cuando ambas fechas están presentes.
  3. Si la publicación es anterior al nacimiento, se lanza la excepción.
  4. Si las fechas son coherentes, el flujo puede continuar hacia la persistencia.

4. Un empleado no puede superar el salario máximo

Sección titulada «4. Un empleado no puede superar el salario máximo»

El salario de un empleado no puede ser superior a 50 millones. El límite se representa mediante una constante de la clase.

private static final Double MAX_SALARY = 50000000.0;
@Transactional
public EmployeeEntity createEmployee(EmployeeEntity emp)
throws IllegalOperationException {
if (emp.getSalary() > MAX_SALARY) {
throw new IllegalOperationException(
"Salario excede el máximo permitido"
);
}
return repository.save(emp);
}
  1. El service recibe la entidad del empleado.
  2. Compara salary con MAX_SALARY.
  3. Si el valor es mayor, lanza la excepción y no persiste.
  4. Si está dentro del límite, guarda la entidad.

5. El nombre del departamento es único dentro de su compañía

Sección titulada «5. El nombre del departamento es único dentro de su compañía»

Esta regla es contextual: dos departamentos pueden compartir nombre si pertenecen a compañías diferentes. Por eso la consulta combina el identificador de la compañía y el nombre.

// Consulta derivada por convención
List<DepartmentEntity> findByCompanyIdAndName(
Long companyId,
String name
);
@Transactional
public DepartmentEntity createDepartment(
Long companyId,
DepartmentEntity dept)
throws IllegalOperationException {
List<DepartmentEntity> existing =
deptRepo.findByCompanyIdAndName(
companyId,
dept.getName()
);
if (!existing.isEmpty()) {
throw new IllegalOperationException(
"Ya existe un departamento con ese nombre "
+ "en esta compañía"
);
}
return deptRepo.save(dept);
}
  1. El service recibe el companyId y el departamento nuevo.
  2. El repository busca el mismo nombre únicamente dentro de esa compañía.
  3. Si encuentra resultados, lanza la excepción y no ejecuta save().
  4. Si no encuentra resultados, persiste el departamento.

6. La editorial debe existir al crear un libro

Sección titulada «6. La editorial debe existir al crear un libro»

El libro debe estar asociado con una editorial válida. No basta con recibir un identificador: el service comprueba que ese registro realmente exista antes de guardar el libro.

@Transactional
public BookEntity createBook(BookEntity bookEntity)
throws IllegalOperationException {
// 1. Verificar que venga la información
if (bookEntity.getEditorial() == null) {
throw new IllegalOperationException(
"La editorial no es válida"
);
}
// 2. Buscar la editorial en la base de datos
Optional<EditorialEntity> editorial =
editorialRepository.findById(
bookEntity.getEditorial().getId()
);
// 3. Validar existencia
if (editorial.isEmpty()) {
throw new IllegalOperationException(
"La editorial no existe"
);
}
// 4. Asignar la entidad persistida y guardar
bookEntity.setEditorial(editorial.get());
return bookRepository.save(bookEntity);
}
  1. Se comprueba que el libro incluya información de la editorial.
  2. findById consulta el identificador recibido.
  3. Si el Optional está vacío, se lanza la excepción y no se guarda el libro.
  4. Si existe, se asigna la EditorialEntity obtenida desde la base de datos.
  5. Finalmente se ejecuta bookRepository.save(bookEntity).

Ya viste ejemplos de create con una regla de negocio adentro. En general, cada <Entidad>Service expone los mismos cinco métodos:

Método Qué hace
findAll Retorna todos los registros. No valida nada.
findById Busca por id. Si no existe, lanza EntityNotFoundException.
create Valida las reglas de negocio y luego guarda.
update Reutiliza findById (para el 404), valida las mismas reglas que create sobre los valores nuevos y luego guarda los cambios.
delete Reutiliza findById (para el 404) y luego borra. Qué pasa con lo asociado lo define el mapeo de la asociación.

update y delete reutilizan findById en vez de repetir la consulta: si el id no existe, la misma excepción y el mismo código HTTP aplican en los tres métodos.

update no puede limitarse a comprobar que el id exista. Los valores nuevos llegan en el cuerpo de la petición y pueden violar las mismas reglas que un create: si solo se validara el id, un PUT dejaría un registro en un estado que create habría rechazado, por ejemplo con el nombre vacío. Por eso create y update comparten sus validaciones. Hay un matiz en las reglas de unicidad: deben ignorar al propio registro que se actualiza, porque si conserva su nombre, la consulta lo encuentra a él mismo.

En delete no hay valores que validar, pero sí hay que decidir qué pasa con lo asociado, y esa decisión se declara en el mapeo, no en el Service. Sin cascade, la llave foránea impide borrar una entidad a la que todavía apuntan otras y la base lanza DataIntegrityViolationException; con cascade = CascadeType.ALL y orphanRemoval se borran también los dependientes, que es lo que corresponde a una composición. El Service agrega una comprobación explícita solo cuando quiere responder con un mensaje claro (IllegalOperationException, 400) en lugar de dejar pasar la excepción de la base, o cuando la regla no se puede expresar con el mapeo.

public BookEntity findById(Long id) throws EntityNotFoundException {
Optional<BookEntity> book = repository.findById(id);
if (book.isEmpty()) {
throw new EntityNotFoundException("El libro no existe");
}
return book.get();
}
public void delete(Long id) throws EntityNotFoundException {
BookEntity book = findById(id);
repository.delete(book);
}

Las dos excepciones de esta capa son clases sencillas que extienden Exception. El service no conoce HTTP: solo las lanza. Quien las traduce a un código HTTP, con el mensaje en el cuerpo de la respuesta, es un manejador de excepciones del controller (@RestControllerAdvice), que se explica en Implementación de un API REST con Spring:

public class EntityNotFoundException extends Exception {
public EntityNotFoundException(String message) {
super(message);
}
}
public class IllegalOperationException extends Exception {
public IllegalOperationException(String message) {
super(message);
}
}

En un proyecto con varios módulos estas dos clases se crean una sola vez, en un paquete compartido del grupo (por ejemplo co.edu.uniandes.musical.exception), y todos los Services las importan de ahí. Si cada módulo tiene las suyas, el manejador de excepciones solo reconoce una y las demás no devuelven el código esperado. El tutorial del Service muestra dónde crearlas.

Asociar dos entidades que ya existen también es lógica de negocio: hay que comprobar que las dos existen, que la asociación no está repetida y, solo entonces, guardarla. Por convención esa lógica no se agrega al BookService: va en una clase propia, <Entidad><Asociada>Service, que para la asociación entre libros y autores es BookAuthorService. Así cada clase tiene una sola responsabilidad y el servicio de la entidad no crece con cada relación nueva.

@Service
public class BookAuthorService {
private final BookRepository bookRepository;
private final AuthorRepository authorRepository;
public BookAuthorService(BookRepository bookRepository, AuthorRepository authorRepository) {
this.bookRepository = bookRepository;
this.authorRepository = authorRepository;
}
@Transactional
public BookEntity addAuthor(Long bookId, Long authorId)
throws EntityNotFoundException, IllegalOperationException {
BookEntity book = bookRepository.findById(bookId)
.orElseThrow(() -> new EntityNotFoundException("El libro no existe"));
AuthorEntity author = authorRepository.findById(authorId)
.orElseThrow(() -> new EntityNotFoundException("El autor no existe"));
boolean yaAsociado = book.getAuthors().stream()
.anyMatch(a -> a.getId().equals(authorId));
if (yaAsociado) {
throw new IllegalOperationException("El libro ya tiene ese autor");
}
book.getAuthors().add(author);
return bookRepository.save(book);
}
}

Es el mismo patrón de las reglas: consultar, evaluar, lanzar y persistir. Hay tres diferencias respecto a un create:

  • Los dos identificadores vienen en la URL, así que si alguno no existe la excepción es EntityNotFoundException (404), no IllegalOperationException. La regla de la editorial de este mismo tema es distinta: ahí el identificador viaja en el cuerpo.
  • La regla “no repetida” se evalúa sobre la colección del propietario, y se compara por id.
  • Se modifica el lado propietario de la relación (aquí BookEntity) y se guarda ese. Agregar el libro a author.getBooks() no guardaría nada: el lado inverso no escribe en la tabla intermedia (ver Mapeo de asociaciones en JPA).

Un servicio de asociación suele ofrecer tres métodos:

Método Qué hace
getAuthors Retorna los autores del libro. Lanza 404 si el libro no existe.
addAuthor Asocia un autor existente a un libro existente. Lanza 400 si ya estaban asociados.
removeAuthor Quita la asociación; no borra al autor. Lanza 400 si no estaban asociados.

Comprobar que los dos identificadores existen y que la asociación no está repetida es lo mínimo, pero no agota las reglas. Pueden existir reglas sobre la información de lo que se asocia o sobre la cardinalidad de la colección, y se evalúan con el mismo patrón (consultar, evaluar, lanzar) antes de guardar. Algunos ejemplos, que no tienen por qué estar en el código de este tema:

  • Sobre la información de lo que se asocia: un libro no puede asociarse a un autor que murió antes de la fecha de publicación del libro (compara datos de las dos entidades, como la regla 3); o un autor marcado como inactivo no puede asociarse a un libro nuevo.
  • Sobre la cardinalidad: un libro no puede tener más de tres autores (un tope en la colección); y al revés, un libro debe conservar al menos un autor, así que removeAuthor no puede quitar el último.

Todas lanzan IllegalOperationException (400) y no EntityNotFoundException: los dos identificadores sí existen, es la operación la que viola una regla. El taller de reglas de negocio las recorre para las asociaciones del proyecto.

1. ¿Qué anotación identifica una clase de lógica de negocio?
2. ¿Qué hace @Autowired?
3. ¿Con qué objetos trabaja la capa de servicios en este modelo?
4. ¿Qué debe ocurrir al violarse una regla de negocio?
5. ¿Cómo accede un servicio a la base de datos?
6. ¿Cómo recibe el service la implementación del repository en este curso?
7. ¿Por qué update y delete reutilizan findById en vez de repetir la consulta?
8. ¿Qué excepción lanza addAuthor(bookId, authorId) si el autor no existe?
9. ¿Por qué addAuthor modifica book.getAuthors() y no author.getBooks()?
10. Además de comprobar que el id exista, ¿qué debe hacer update antes de guardar?