Inter-service communication choices (RestClient, OpenFeign, Kafka/RabbitMQ), @Async and its pitfalls, sending welcome emails reliably (after commit, async, retried), monitoring async tasks, notification queues, event-driven design with application events and brokers, and an IoT ingestion backend.
Published September 25, 2026
Async and event questions probe reliability. What happens if the email server is down, if the app crashes after the database commit but before the message is sent, or if a message is delivered twice? Answer with the happy path, then with the failure path. The failure path is what gets you hired.
Short answer: Match the style to the need:
RestClient (Spring 6.1+, the modern blocking client), Spring Cloud OpenFeign (declarative interfaces), or WebClient in reactive apps. For internal high-throughput calls, gRPC. Always with timeouts, retries and circuit breakers, and service discovery or load balancing.@HttpExchange("/api/inventory")
interface InventoryClient { // Spring 6 declarative HTTP interface
@GetExchange("/{sku}") StockLevel stock(@PathVariable String sku);
}
Key points to cover:
RestTemplate is in maintenance mode. Prefer RestClient or HTTP interfaces in new code.Learn it in depth → Inter-Service Communication Choices
Short answer: With @EnableAsync and @Async, a method call is intercepted by a proxy, and executed on a TaskExecutor instead of the caller's thread. Boot auto-configures a ThreadPoolTaskExecutor (spring.task.execution.*), or virtual threads when spring.threads.virtual.enabled=true. Methods return void or CompletableFuture<T>.
Key points to cover:
CompletableFuture composition, @Scheduled background jobs, Spring MVC async request handling (DeferredResult), and messaging listeners.@Async correctly?Short answer:
@EnableAsync.@Async.CompletableFuture carries the exceptions, and an AsyncUncaughtExceptionHandler handles void methods.@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Override public Executor getAsyncExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8); ex.setMaxPoolSize(16); ex.setQueueCapacity(500);
ex.setThreadNamePrefix("async-");
ex.setTaskDecorator(new ContextCopyingDecorator()); // propagate MDC / security context
ex.initialize();
return ex;
}
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (e, method, params) -> log.error("Async failure in {}", method.getName(), e);
}
}
Key points to cover:
@Async work is lost on crash or redeploy, because it lives only in memory. For work that must happen, use a durable queue.Short answer: Send it after the registration transaction commits, asynchronously, with retries. That way, a slow or failing mail server never breaks registration, and you never email someone whose registration was rolled back.
@Service
class RegistrationService {
@Transactional
public User register(SignupRequest req) {
User user = users.save(User.from(req));
events.publishEvent(new UserRegistered(user.getId(), user.getEmail())); // published inside the transaction
return user;
}
}
@Component
class WelcomeEmailListener {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // only if the save committed
@Retryable(retryFor = MailException.class, backoff = @Backoff(delay = 2000, multiplier = 2))
public void onRegistered(UserRegistered e) {
mailService.sendWelcome(e.email()); // JavaMailSender + a template
}
}
Key points to cover:
spring-boot-starter-mail (spring.mail.*), or use an email provider's API (SES, SendGrid).Short answer:
@Async):
CompletableFuture, and attach handlers (whenComplete, exceptionally).ThreadPoolTaskExecutor.executor.active, executor.queued, executor.completed).TaskDecorator.GET /jobs/{id}). Retry failures with backoff, and alert on stuck or failed jobs.Short answer (Kafka example; RabbitMQ is analogous with RabbitTemplate and @RabbitListener):
spring-kafka, and configure spring.kafka.bootstrap-servers, the serializers and a consumer group.KafkaTemplate.send(topic, key, event), keyed by user ID so each user's messages stay in order.@KafkaListener, making processing idempotent, because delivery is at least once.DefaultErrorHandler with backoff, and a dead-letter topic for poison messages.@Service
class NotificationPublisher {
private final KafkaTemplate<String, NotificationEvent> kafka;
void publish(NotificationEvent e) { kafka.send("notifications", e.userId(), e); }
}
@Component
class NotificationConsumer {
@KafkaListener(topics = "notifications", groupId = "notification-service")
void handle(NotificationEvent e) {
if (processed.alreadyHandled(e.id())) return; // idempotency
channelRouter.deliver(e);
processed.markHandled(e.id());
}
}
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
return new DefaultErrorHandler(new DeadLetterPublishingRecoverer(template), new ExponentialBackOff(1000, 2));
}
Common trap: inventing annotations such as @EnableMessaging, or calling convertAndSend on KafkaTemplate. Kafka uses send. convertAndSend belongs to RabbitTemplate and JmsTemplate.
Learn it in depth → Messaging Technology Choices
Short answer: At two levels:
ApplicationEventPublisher.publishEvent(anyObject) (since Spring 4.2, events don't need to extend ApplicationEvent), and handle them with @EventListener, or @TransactionalEventListener (tied to commit or rollback). Add @Async for non-blocking listeners. Modules stay decoupled.Key points to cover:
Learn it in depth → Event-Driven Architecture Patterns
Short answer:
Learn it in depth → Real-Time Analytics Dashboard
Short answer:
OrderPlaced, PaymentFailed).That removes direct dependencies (and circular ones) between modules.
public record OrderPlaced(long orderId, long customerId, BigDecimal total) { }
@Service
class OrderService {
private final ApplicationEventPublisher events;
@Transactional public Order place(Cart cart) {
Order o = orders.save(Order.from(cart));
events.publishEvent(new OrderPlaced(o.getId(), o.getCustomerId(), o.getTotal()));
return o;
}
}
@Component class LoyaltyListener {
@TransactionalEventListener void award(OrderPlaced e) { loyalty.addPoints(e.customerId(), e.total()); }
}
@Component class AnalyticsListener {
@Async @EventListener void track(OrderPlaced e) { analytics.record(e); }
}
Key points to cover:
@EventListeners run in the publisher's thread and transaction. An exception in a listener fails the publisher. Use @TransactionalEventListener(AFTER_COMMIT) plus @Async for side effects that must not affect the main flow.Q: What's the difference between @EventListener and @TransactionalEventListener?
A: @EventListener runs immediately when the event is published. @TransactionalEventListener is deferred to a transaction phase (by default AFTER_COMMIT), so it never acts on changes that are later rolled back. It doesn't run at all if there's no transaction, unless fallbackExecution = true.
Q: How do you guarantee exactly-once processing with Kafka in Spring? A: End to end, within Kafka (consume → process → produce), use transactions and idempotent producers. When side effects touch external systems, rely on idempotent consumers (deduplicating by event ID) instead, because exactly-once can't extend to arbitrary external systems.
Q: Why do @Async methods lose the security context or MDC?
A: They run on another thread, and both are ThreadLocal-based. Use a TaskDecorator (or DelegatingSecurityContextAsyncTaskExecutor) to copy the context onto the task's thread.
Q: Kafka or RabbitMQ for notifications? A: RabbitMQ suits task queues with per-message routing, priorities and simple work distribution. Kafka suits high-volume event streams that multiple consumers replay independently, and it keeps history. Both work for notifications. Choose based on the throughput you need, how important replay is, and what your team already operates.