Architecture Event-Driven
Cette page présente l’architecture event-driven dans une architecture hexagonale : events locaux, messaging asynchrone et intégration Kafka.
Pourquoi Event-Driven ?
L’architecture event-driven permet de découpler les composants en utilisant des événements au lieu d’appels directs.
❌ Couplage Direct
Approche couplée
@Singleton
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final AuditService auditService;
public User createUser(CreateUserRequest request) {
User user = new User(request.email(), request.name());
User saved = userRepository.save(user);
// Couplage fort : UserService dépend directement de EmailService et AuditService
emailService.sendWelcomeEmail(saved.getEmail());
auditService.logUserCreation(saved.getId());
return saved;
}
}Problèmes :
- Couplage fort entre services
- Si EmailService échoue, la création d’utilisateur échoue aussi
- Difficile de tester UserService isolément
- Impossible d’ajouter de nouveaux listeners sans modifier UserService
Event-Driven = Découplage + Résilience + Extensibilité
Events Locaux avec Micronaut
Les events locaux sont des événements publiés et consommés dans la même application.
Définir l’événement
Les événements sont des records Java immuables :
package com.example.domain.events;
import java.time.Instant;
public record UserCreatedEvent(
Long userId,
String email,
Instant createdAt
) {
public UserCreatedEvent(Long userId, String email) {
this(userId, email, Instant.now());
}
}Utilisez des records pour les événements : immuables, concis, avec equals/hashCode automatiques.
Publier l’événement
Injectez ApplicationEventPublisher<T> dans votre service :
import io.micronaut.context.event.ApplicationEventPublisher;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@Singleton
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher<UserCreatedEvent> eventPublisher;
public User createUser(CreateUserRequest request) {
User user = new User(request.email(), request.name());
User saved = userRepository.save(user);
// Publier l'événement
eventPublisher.publishEvent(
new UserCreatedEvent(saved.getId(), saved.getEmail())
);
return saved;
}
}Écouter l’événement
Utilisez @EventListener pour réagir aux événements :
import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.runtime.event.annotation.EventListener;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
@RequiredArgsConstructor
public class UserEventListener {
private final EmailService emailService;
private final AuditService auditService;
@EventListener
public void onUserCreated(UserCreatedEvent event) {
log.info("User created: {}", event.userId());
// Envoyer email de bienvenue
emailService.sendWelcomeEmail(event.email());
// Audit log
auditService.logUserCreation(event.userId());
}
}Les events locaux sont synchrones par défaut : le listener s’exécute dans le même thread que le publisher.
Events Asynchrones
Pour exécuter les listeners en arrière-plan, utilisez @Async.
Synchrone (défaut)
Event synchrone
@EventListener
public void onUserCreated(UserCreatedEvent event) {
// S'exécute dans le thread du publisher
emailService.sendWelcomeEmail(event.email()); // Bloquant
}Flux d’exécution :
createUser() → publishEvent() → onUserCreated() → sendEmail() → retour
↑_______________↓
(même thread, bloquant)Attention : les events asynchrones ne garantissent pas l’ordre d’exécution ni la gestion des erreurs automatique.
Domain Events
Les domain events sont des événements métier publiés depuis le domain layer.
- UserCreatedEvent.java
- OrderPlacedEvent.java
- PaymentProcessedEvent.java
Exemple complet
Domain Event
Événement métier
package com.example.domain.events;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderPlacedEvent(
Long orderId,
Long userId,
BigDecimal totalAmount,
Instant placedAt
) {
public OrderPlacedEvent(Long orderId, Long userId, BigDecimal totalAmount) {
this(orderId, userId, totalAmount, Instant.now());
}
}Bonne pratique : séparer les listeners par responsabilité (email, inventory, analytics).
Messaging avec Kafka
Pour communiquer entre applications, utilisez Kafka avec Micronaut Kafka.
Ajouter la dépendance
<!-- pom.xml -->
<dependency>
<groupId>io.micronaut.kafka</groupId>
<artifactId>micronaut-kafka</artifactId>
</dependency>Configurer Kafka
# application.yml
kafka:
bootstrap:
servers: localhost:9092
producers:
default:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: io.micronaut.kafka.serde.JsonSerde
consumers:
default:
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: io.micronaut.kafka.serde.JsonSerde
group-id: my-app
auto-offset-reset: earliestCréer le Producer
import io.micronaut.configuration.kafka.annotation.KafkaClient;
import io.micronaut.configuration.kafka.annotation.Topic;
@KafkaClient
public interface UserEventProducer {
@Topic("user-events")
void sendUserCreated(UserCreatedEvent event);
}Publier l’événement
@Singleton
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserEventProducer eventProducer;
public User createUser(CreateUserRequest request) {
User user = new User(request.email(), request.name());
User saved = userRepository.save(user);
// Publier dans Kafka
eventProducer.sendUserCreated(
new UserCreatedEvent(saved.getId(), saved.getEmail())
);
return saved;
}
}Consommer l’événement
import io.micronaut.configuration.kafka.annotation.KafkaListener;
import io.micronaut.configuration.kafka.annotation.Topic;
import io.micronaut.messaging.annotation.MessageBody;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
@KafkaListener(groupId = "email-service")
public class UserEventConsumer {
@Topic("user-events")
public void onUserCreated(@MessageBody UserCreatedEvent event) {
log.info("Received UserCreatedEvent: {}", event.userId());
// Traiter l'événement
emailService.sendWelcomeEmail(event.email());
}
}Kafka vs Events Locaux :
- Events locaux : même application, synchrone ou async
- Kafka : inter-applications, durable, scalable
Architecture Event-Driven Complète
Voici une architecture complète combinant events locaux et Kafka.
- UserCreatedEvent.java
- OrderPlacedEvent.java
Flux de données
1. User fait une requête HTTP
↓
2. Controller appelle Use Case
↓
3. Use Case publie Event Local
↓
4. Listener Local traite l'event (audit, cache)
↓
5. Listener Local publie dans Kafka
↓
6. Autres microservices consomment depuis KafkaImplémentation
Use Case
Use Case
@Singleton
@RequiredArgsConstructor
public class CreateUserUseCase {
private final UserRepository userRepository;
private final ApplicationEventPublisher<UserCreatedEvent> eventPublisher;
public User execute(CreateUserRequest request) {
User user = new User(request.email(), request.name());
User saved = userRepository.save(user);
// Publier event local
eventPublisher.publishEvent(
new UserCreatedEvent(saved.getId(), saved.getEmail())
);
return saved;
}
}Pattern recommandé : events locaux pour les traitements internes + Kafka pour les communications inter-services.
Gestion des Erreurs
Les erreurs dans les listeners doivent être gérées avec soin.
Sans gestion
Sans gestion d’erreurs
@EventListener
public void onUserCreated(UserCreatedEvent event) {
// Si sendEmail() lève une exception, elle remonte au publisher
emailService.sendEmail(event.email()); // ❌ Peut échouer
}Problème : l’exception remonte et peut bloquer le publisher.
Best practice : toujours gérer les erreurs dans les listeners asynchrones pour éviter les erreurs silencieuses.
Event Sourcing (Introduction)
L’event sourcing consiste à stocker tous les événements métier au lieu de l’état final.
CRUD traditionnel
CRUD traditionnel
// État final stocké en BDD
User user = new User(1L, "john@example.com", "active");
userRepository.save(user);
// Mise à jour : écrase l'ancien état
user.setStatus("inactive");
userRepository.save(user);
// ❌ On ne sait pas POURQUOI le status a changéEvent Sourcing est un pattern avancé nécessitant une infrastructure dédiée (event store). Pour débuter, utilisez les events locaux et Kafka.
Tests
Tester la publication d’événements
@MicronautTest
class CreateUserUseCaseTest {
@Inject
CreateUserUseCase useCase;
@MockBean(ApplicationEventPublisher.class)
ApplicationEventPublisher<UserCreatedEvent> eventPublisher() {
return mock(ApplicationEventPublisher.class);
}
@Test
void shouldPublishEventOnUserCreation() {
// Given
CreateUserRequest request = new CreateUserRequest("john@example.com", "John");
// When
useCase.execute(request);
// Then
verify(eventPublisher).publishEvent(argThat(event ->
event.email().equals("john@example.com")
));
}
}Tester un listener
@MicronautTest
class UserEventListenerTest {
@Inject
UserEventListener listener;
@MockBean(EmailService.class)
EmailService emailService() {
return mock(EmailService.class);
}
@Test
void shouldSendEmailOnUserCreated() {
// Given
UserCreatedEvent event = new UserCreatedEvent(1L, "john@example.com");
// When
listener.onUserCreated(event);
// Then
verify(emailService).sendWelcomeEmail("john@example.com");
}
}Bonnes Pratiques
Règles d’or pour les events :
- Nommage : utiliser le passé (
UserCreatedEvent, pasCreateUserEvent) - Immuabilité : utiliser des
recordJava pour les événements - Contenu : inclure uniquement les données nécessaires (pas toute l’entité)
- Versioning : prévoir l’évolution du schéma (ajouter des champs optionnels)
- Idempotence : les listeners doivent supporter le retraitement du même événement
- Gestion d’erreurs : toujours wrapper les listeners avec try-catch
- Tests : tester la publication ET la consommation des événements
Comparaison des Approches
| Approche | Scope | Latence | Durabilité | Scalabilité | Complexité |
|---|---|---|---|---|---|
| Events Locaux | Même app | Basse | Non | Limitée | Faible |
| Events Async | Même app | Moyenne | Non | Moyenne | Faible |
| Kafka | Inter-apps | Moyenne | Oui | Haute | Moyenne |
| Event Sourcing | Même app | Haute | Oui | Haute | Haute |
Références
Prochaine étape : Ports GraphQL