ApplicationEvents: desacoplamiento intra-aplicación

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-application-events

Acoplamiento que aparece sin querer

Cuando un servicio crece, las dependencias se multiplican. Un caso típico: al crear un empleado, hay que enviar un email de bienvenida, registrar una entrada de auditoría, invalidar la caché del departamento al que pertenece, notificar al servicio de nóminas y publicar un evento a Kafka.

La solución directa acopla todo a EmpleadoService:

@Service
@RequiredArgsConstructor
public class EmpleadoService {

    private final EmpleadoRepository repository;
    private final EmailSenderService emailSender;
    private final AuditService auditService;
    private final CacheManager cacheManager;
    private final NominaClient nominaClient;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Transactional
    public EmpleadoDto crear(EmpleadoCrearDto dto) {
        var empleado = repository.save(toEntity(dto));
        emailSender.enviarBienvenida(empleado.getEmail());
        auditService.registrarCreacion(empleado);
        cacheManager.getCache("departamentos").evict(empleado.getDepartamentoId());
        nominaClient.notificarNuevoEmpleado(empleado.getId());
        kafkaTemplate.send("empleados.creados", new EmpleadoCreadoEvent(empleado.getId()));
        return toDto(empleado);
    }
}

Seis dependencias para un método. Cada vez que se añade un side effect, se añade una dependencia más. El servicio se vuelve frágil: cualquier cambio en el flujo obliga a tocar EmpleadoService. Y, peor: si el envío de email falla, ¿debe fallar la creación del empleado?

ApplicationEvents invierten el flujo. EmpleadoService solo publica un evento. Los demás servicios escuchan ese evento y reaccionan en independencia.

Estructura básica

1. Definir el evento

Un evento es un POJO (record desde Java 14). No tiene por qué extender de nada en Spring 4.2+:

public record EmpleadoCreadoEvent(
    Long empleadoId,
    String email,
    Long departamentoId,
    BigDecimal salario,
    Instant timestamp
) {}

2. Publicar el evento

ApplicationEventPublisher se inyecta como cualquier bean:

@Service
@RequiredArgsConstructor
public class EmpleadoService {

    private final EmpleadoRepository repository;
    private final ApplicationEventPublisher events;

    @Transactional
    public EmpleadoDto crear(EmpleadoCrearDto dto) {
        var empleado = repository.save(toEntity(dto));
        events.publishEvent(new EmpleadoCreadoEvent(
            empleado.getId(),
            empleado.getEmail(),
            empleado.getDepartamentoId(),
            empleado.getSalario(),
            Instant.now()
        ));
        return toDto(empleado);
    }
}

El servicio queda con dos dependencias: el repositorio y el publisher. El resto de side effects vive en otros servicios.

3. Escuchar el evento

Cualquier bean Spring puede escuchar con @EventListener:

@Component
@RequiredArgsConstructor
@Slf4j
public class EmpleadoCreadoEmailListener {

    private final EmailSenderService emailSender;

    @EventListener
    public void enviarBienvenida(EmpleadoCreadoEvent event) {
        log.info("Enviando email de bienvenida a empleado {}", event.empleadoId());
        emailSender.enviarBienvenida(event.email());
    }
}

@Component
@RequiredArgsConstructor
public class EmpleadoCreadoAuditoriaListener {

    private final AuditService auditService;

    @EventListener
    public void registrarAuditoria(EmpleadoCreadoEvent event) {
        auditService.registrarCreacion(event.empleadoId(), event.timestamp());
    }
}

@Component
@RequiredArgsConstructor
public class EmpleadoCreadoCacheListener {

    private final CacheManager cacheManager;

    @EventListener
    public void invalidarCacheDepartamento(EmpleadoCreadoEvent event) {
        cacheManager.getCache("departamentos").evict(event.departamentoId());
    }
}

Cada listener vive en su clase, con sus dependencias. El servicio principal no sabe nada de ellos.

Comportamiento síncrono por defecto

Por defecto, los listeners se ejecutan síncronamente en el mismo thread y dentro de la misma transacción que el publisher. Esto tiene implicaciones importantes:

  • Si el listener falla con una excepción, la transacción se hace rollback (a menos que se configure lo contrario).
  • El método publishEvent() no retorna hasta que todos los listeners han terminado.
  • No hay concurrencia: los listeners corren uno tras otro en el orden registrado.

Para el ejemplo anterior, esto es un problema: si el email falla, se desharía la creación del empleado. Y aunque el email tarde 3 segundos, el endpoint REST tarda 3 segundos.

@TransactionalEventListener: ligar al ciclo de la transacción

Lo idiomático para listeners que tienen side effects (email, llamadas externas, publicación a Kafka) es @TransactionalEventListener:

@Component
@RequiredArgsConstructor
public class EmpleadoCreadoEmailListener {

    private final EmailSenderService emailSender;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void enviarBienvenida(EmpleadoCreadoEvent event) {
        emailSender.enviarBienvenida(event.email());
    }
}

Con phase = AFTER_COMMIT, el listener solo se ejecuta si la transacción del publisher se confirma. Si la transacción hace rollback, el listener no se llama. Eso es justo lo que se quiere: no enviar email de bienvenida si la creación del empleado se ha deshecho.

Las fases disponibles:

| Fase | Cuándo se ejecuta | |------|-------------------| | BEFORE_COMMIT | Justo antes del commit. Última oportunidad de ver los cambios y modificarlos. | | AFTER_COMMIT (por defecto) | Justo después del commit exitoso. Listener solo se ejecuta si la transacción se confirmó. | | AFTER_ROLLBACK | Justo después de un rollback. Útil para compensaciones. | | AFTER_COMPLETION | Después de la transacción, sea commit o rollback. |

Si no hay transacción activa cuando se publica el evento, el listener se llama inmediatamente (comportamiento por defecto) o no se llama si se especifica fallbackExecution = false.

Regla práctica: para listeners con side effects (email, HTTP externo, Kafka), usar @TransactionalEventListener(phase = AFTER_COMMIT). Para listeners que ven datos de la transacción (auditoría que se guarda en la misma BD) o que pueden fallar y forzar rollback, usar @EventListener síncrono.

@Async para procesamiento en otro thread

Si el listener no debe bloquear al publisher, se hace asíncrono con @Async:

@Component
@RequiredArgsConstructor
public class EmpleadoCreadoEmailListener {

    private final EmailSenderService emailSender;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    public void enviarBienvenida(EmpleadoCreadoEvent event) {
        emailSender.enviarBienvenida(event.email());
    }
}

Con @Async, el listener se ejecuta en un thread del executor configurado. El publisher retorna inmediatamente. Si el listener falla, la excepción no propaga al publisher (queda en el log, hay que añadir manejo explícito).

@Async requiere activar el procesamiento asíncrono en la aplicación principal:

@SpringBootApplication
@EnableAsync
public class EmpleadosApplication { ... }

Y configurar el executor (lección dedicada a @Async lo cubre en detalle):

@Configuration
public class AsyncConfig {

    @Bean(name = "applicationTaskExecutor")
    public TaskExecutor applicationTaskExecutor() {
        var executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("app-");
        executor.initialize();
        return executor;
    }
}

Con Spring Boot 4 y Java 25 LTS, lo más eficiente para tareas IO-bound es usar Virtual Threads:

@Bean(name = "applicationTaskExecutor")
public TaskExecutor virtualThreadExecutor() {
    return new SimpleAsyncTaskExecutor("vt-")
        .virtualThreads(true);
}

Filtros con condition

@EventListener admite una expresión SpEL para filtrar eventos:

@EventListener(condition = "#event.salario.compareTo(T(java.math.BigDecimal).valueOf(100000)) > 0")
public void notificarEmpleadoTopSalary(EmpleadoCreadoEvent event) {
    // Solo se ejecuta si el salario supera 100.000
}

El SpEL accede al evento como #event. Útil para evitar lógica condicional en el cuerpo del listener.

Eventos genéricos

Para eventos que extienden de un tipo común:

public sealed interface DominioEvent permits EmpleadoCreadoEvent, EmpleadoActualizadoEvent, EmpleadoEliminadoEvent {}

public record EmpleadoCreadoEvent(...) implements DominioEvent {}
public record EmpleadoActualizadoEvent(...) implements DominioEvent {}
public record EmpleadoEliminadoEvent(...) implements DominioEvent {}

@Component
public class AuditListener {

    @EventListener
    public void auditarCualquierCambio(DominioEvent event) {
        // Captura los tres eventos
    }

    @EventListener
    public void auditarCreacionEspecifica(EmpleadoCreadoEvent event) {
        // Solo el de creación
    }
}

Java 25 + sealed interfaces hacen que el conjunto de eventos sea cerrado y exhaustivo, lo que ayuda a que el compilador detecte casos no manejados.

Manejo de errores en listeners asíncronos

Cuando un listener @Async falla, la excepción se pierde. Hay que registrarla con un AsyncUncaughtExceptionHandler:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        // ... como antes
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            log.error("Excepción en listener asíncrono {}.{}: {}",
                method.getDeclaringClass().getSimpleName(),
                method.getName(),
                throwable.getMessage(),
                throwable);
            // Métricas: incrementar contador de errores async
        };
    }
}

Esto solo aplica a métodos void. Si el método devuelve CompletableFuture<T>, la excepción se propaga por el future y el llamador puede manejarla.

Eventos vs Kafka: cuándo usar cada uno

| Característica | ApplicationEvents | Kafka | |----------------|-------------------|-------| | Ámbito | Misma JVM, misma aplicación | Múltiples aplicaciones, múltiples instancias | | Persistencia | No (en memoria) | Sí (durante días o semanas) | | Garantía de entrega | Best-effort | At-least-once / exactly-once | | Latencia típica | Microsegundos | Milisegundos | | Retransmisión tras crash | No | Sí | | Coste operativo | Cero (solo Spring) | Alto (cluster Kafka) | | Casos de uso | Desacoplar lógica dentro de un módulo | Comunicación entre microservicios |

Un proyecto bien diseñado usa ambos: ApplicationEvents para desacoplar dentro de un servicio (auditoría, cache, email síncrono) y Kafka para comunicar entre servicios (creación de empleado en Servicio A → notificación en Servicio B). El uso correcto evita el anti-patrón de Kafka como bus de eventos intra-aplicación, que añade complejidad sin beneficio.

Patrón completo aplicado al ejemplo

// EmpleadoService limpio: solo lógica de empleado + publish
@Service
@RequiredArgsConstructor
public class EmpleadoService {

    private final EmpleadoRepository repository;
    private final ApplicationEventPublisher events;

    @Transactional
    public EmpleadoDto crear(EmpleadoCrearDto dto) {
        var empleado = repository.save(toEntity(dto));
        events.publishEvent(new EmpleadoCreadoEvent(
            empleado.getId(), empleado.getEmail(),
            empleado.getDepartamentoId(), empleado.getSalario(),
            Instant.now()
        ));
        return toDto(empleado);
    }
}

// Listener síncrono dentro de la transacción: auditoría en BD
@Component
@RequiredArgsConstructor
public class AuditoriaListener {

    private final AuditLogRepository repository;

    @EventListener
    public void auditar(EmpleadoCreadoEvent e) {
        repository.save(AuditLog.from(e));
    }
}

// Listener async post-commit: email
@Component
@RequiredArgsConstructor
public class EmailListener {

    private final EmailSenderService email;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async
    public void enviarBienvenida(EmpleadoCreadoEvent e) {
        email.enviarBienvenida(e.email());
    }
}

// Listener async post-commit: cache eviction
@Component
@RequiredArgsConstructor
public class CacheListener {

    private final CacheManager cacheManager;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async
    public void invalidarDepartamento(EmpleadoCreadoEvent e) {
        cacheManager.getCache("departamentos").evict(e.departamentoId());
    }
}

// Listener async post-commit: publish a Kafka para servicios externos
@Component
@RequiredArgsConstructor
public class KafkaPublishListener {

    private final KafkaTemplate<String, Object> kafkaTemplate;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async
    public void publicarAKafka(EmpleadoCreadoEvent e) {
        kafkaTemplate.send("empleados.creados", e);
    }
}

EmpleadoService ha pasado de seis dependencias a dos. Cada side effect vive aislado, se puede testear independientemente, y desactivar uno (por ejemplo, comentar el listener de email en un perfil de test) no afecta al servicio principal.

Buenas prácticas

  • Evento como record inmutable con timestamp y correlation ID si aplica.
  • Listeners por responsabilidad, no megaclases con varios @EventListener.
  • AFTER_COMMIT por defecto para side effects con consecuencia externa (email, HTTP, Kafka).
  • @Async para listeners que no bloquean al publisher, pero asegurar manejo de errores.
  • Tests de listeners independientes del servicio que los publica. Cada listener es un componente testeable solo.
  • No abusar: si dos servicios siempre van juntos, una llamada directa es más clara que un evento. Reservar eventos para desacoplamiento real (varios listeners por evento, listeners opcionales según perfil, listeners que pueden añadirse sin tocar el publisher).

ApplicationEvents son una herramienta de modularización dentro de una aplicación. Bien usados, hacen que cada cambio en el flujo no toque el servicio principal. Mal usados, dispersan la lógica y hacen el debugging difícil. La regla es: eventos cuando hay desacoplamiento genuino, llamadas directas en otro caso.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Boot

Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Publicar eventos con ApplicationEventPublisher. Recibir eventos con @EventListener síncronos. Coordinar listeners con fases de transacción usando @TransactionalEventListener. Desacoplar procesamiento con @Async. Decidir cuándo usar eventos intra-app y cuándo Kafka inter-aplicación.