Production · Lesson 84 of 95
Spring Events
Spring Events explained: publish an event with ApplicationEventPublisher, react with @EventListener, set @Order and see when to use async listeners.
In a school, when the principal rings the bell for the end of the day, she does not visit every class. She rings once, and everyone reacts in their own way. Teachers pack their bags, the bus driver starts the engine, the gatekeeper opens the gate. The bell does not know who is listening. Spring Events work the same way. One part of your app announces that something happened, and other parts, which the sender knows nothing about, react.
In this guide you will build an online bookshop where placing an order publishes one event and three listeners react. You will learn how to publish and listen, how to control the order, and when a listener runs on the same thread and when it does not.
What are Spring Events?
There are three pieces:
- The event. A plain Java object, usually a record, that carries the facts: "order 7 was placed by Meera for 1200 rupees".
- The publisher. Any bean that calls
publishEventon anApplicationEventPublisher. - The listeners. Methods marked with
@EventListenerwhose parameter type is the event.
Since Spring 4.2, an event can be any object. You do not need to extend a special base class.
Why is it used?
- Loose coupling. The order service does not need to know about invoices, stock or loyalty points. It just announces the order.
- Easy to extend. To add an SMS after each order, write one new listener. The order code stays untouched.
- Cleaner code. Each listener has one small job and can be tested alone.
- A step towards messaging. The same thinking, "announce, do not command", is what Kafka and other message systems use between apps.
Events do have limits. They live inside one running application, and by default listeners run one after the other on the caller's thread. They are not a message queue.
How it works
textOrderService | | publishEvent(OrderPlaced) v +------------------------+ | Application context | | (the event bus) | +------------------------+ | | | v v v Invoice Stock Loyalty listener listener listener
OrderService gives the event to the application context. The context looks for every listener whose parameter matches the event's type, and calls them one by one. When the last listener finishes, publishEvent returns, and the order service continues. Because the calls happen on the same thread, an exception in a listener travels back to the publisher. This is useful if the listener must be able to stop the action, and a surprise if it should not.
Use @Order on listeners to set their sequence. A smaller number runs first.
Real-Life Example
At a railway station, when the announcer says "Train 12345 has arrived on platform 3", many people react. Porters move to the platform, the tea stall fills flasks, the ticket checker takes position, and passengers pick up their bags. The announcer does not phone each person. He says it once. If a new tea stall opens tomorrow, the announcer does not change his announcement.
Code Example
Let's build BookNook, an online bookshop. When an order is placed, one event goes out. Three listeners react: the invoice, the stock and a loyalty listener that only cares about big orders.
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.booknook</groupId> <artifactId>shop</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: OrderPlaced.java in package com.booknook.shop
javapackage com.booknook.shop; public record OrderPlaced(int orderId, String customer, int amountInRupees) {}
File: OrderService.java in package com.booknook.shop
javapackage com.booknook.shop; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; } public void placeOrder(int orderId, String customer, int amountInRupees) { System.out.println("Order " + orderId + " saved for " + customer); publisher.publishEvent(new OrderPlaced(orderId, customer, amountInRupees)); System.out.println("Order " + orderId + " finished"); } }
File: OrderListeners.java in package com.booknook.shop
javapackage com.booknook.shop; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @Component public class OrderListeners { @EventListener @Order(1) public void createInvoice(OrderPlaced event) { System.out.println(" Invoice created for order " + event.orderId()); } @EventListener @Order(2) public void reduceStock(OrderPlaced event) { System.out.println(" Stock reduced for order " + event.orderId()); } @EventListener(condition = "#event.amountInRupees() >= 1000") @Order(3) public void addLoyaltyPoints(OrderPlaced event) { System.out.println(" Loyalty points added for " + event.customer()); } }
File: ShopApplication.java in package com.booknook.shop
javapackage com.booknook.shop; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } @Bean CommandLineRunner demo(OrderService orders) { return args -> { orders.placeOrder(1, "Meera", 1200); orders.placeOrder(2, "Arjun", 350); }; } }
Run it with ./mvnw spring-boot:run.
Output:
textOrder 1 saved for Meera Invoice created for order 1 Stock reduced for order 1 Loyalty points added for Meera Order 1 finished Order 2 saved for Arjun Invoice created for order 2 Stock reduced for order 2 Order 2 finished
Code Explained
OrderPlacedis a record. Records are perfect for events because they are small, fixed after creation and easy to read.OrderServicedepends only onApplicationEventPublisher. It knows nothing about invoices, stock or loyalty. That is the whole point.- Each
@EventListenermethod takes the event type as its parameter. Spring calls it when an event of that type is published. @Ordersets the sequence: invoice, then stock, then loyalty. Without it the order is not something to rely on.- The
conditionuses SpEL to look at the event. Arjun's order was 350 rupees, so the loyalty listener was skipped for him, and only Meera got points. - "Order 1 finished" appears after all three listeners. That shows a normal listener runs on the caller's thread and the publisher waits for it.
Sync, Async and Transactional Listeners
| Kind | How | Behaviour |
|---|---|---|
| Normal | @EventListener | Runs on the publisher's thread, and the publisher waits |
| Async | @EventListener plus @Async | Runs on a pool thread, and the publisher does not wait |
| Transactional | @TransactionalEventListener | Runs only after the database transaction commits (or rolls back) |
The last one matters in real shops. If you send a "your order is confirmed" email from a normal listener inside a transaction and the transaction then fails, the customer gets an email for an order that does not exist. A @TransactionalEventListener waits for the commit, so the email is sent only when the order really saved.
An async listener needs @EnableAsync. Its errors do not come back to the publisher, so handle them inside.
Common Mistakes
- Expecting a fixed order without `@Order`. Listeners have no promised order by default.
- Slow work in a normal listener. The publisher waits. Move slow work to an async listener.
- Publishing before saving. Listeners may read data that is not saved yet. Publish after the save, or use a transactional listener.
- Too many events. If a simple call would do, use a simple call. Events make the flow harder to follow.
- Expecting events across apps. Spring events stay inside one application. Use a message broker between services.
Interview Questions
How do you publish an event in Spring?
Ans:Inject ApplicationEventPublisher and call publishEvent with any object.
How do you listen for an event?
Ans:Put @EventListener on a method that takes the event type as its parameter.
Are Spring events synchronous?
Ans:Yes, by default. Listeners run on the publisher's thread, one after another. Add @Async for background listeners.
What is `@TransactionalEventListener`?
Ans:A listener that runs at a chosen phase of a transaction, usually after commit, so side effects happen only when data is saved.
How do you run listeners in a fixed order?
Ans:Add @Order to the listener methods. Smaller numbers run first.
Key Points to Remember
- An event is any object, and listeners are methods marked
@EventListener. - Publish with
ApplicationEventPublisher. - Publisher and listeners stay loosely coupled.
- Listeners are synchronous by default, so exceptions travel back to the publisher.
- Use
@Asyncfor background work and@TransactionalEventListenerfor after-commit work.
Frequently Asked Questions
Do Spring events need to extend ApplicationEvent?
No. Since Spring 4.2, any object can be an event. A record works well.
Can one event have many listeners?
Yes. Every matching listener is called. That is the strength of Spring events.
Are Spring events the same as Kafka messages?
No. Spring events live inside one running app and vanish if it stops. Kafka delivers messages between apps and keeps them safely.
Can a listener publish another event?
Yes. A listener can call the publisher again, or return a new event object, and Spring publishes it for you.
Related Topics
- Async Processing with @Async: make listeners run in the background.
- Transactional: the transaction that
@TransactionalEventListenerwaits for. - Sending Email: a typical thing to trigger from a listener.
- Kafka with Spring Boot: events between separate applications.
Practice Problems
Try each problem on your own first. Both are command line programs.
Easy: Seat Booked at the Cinema
StarPlex Cinema wants two things to happen when a seat is booked: an SMS line Ticket SMS sent for seat A5 and an offer line Snack offer sent to Rhea. Publish one SeatBooked event with the seat and the customer name, and let two listeners print the lines in that order.
Show answerHide answer
@Order makes the SMS listener run first.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.starplex</groupId> <artifactId>booking</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: SeatBooked.java in package com.starplex.booking
javapackage com.starplex.booking; public record SeatBooked(String seat, String customer) {}
File: BookingListeners.java in package com.starplex.booking
javapackage com.starplex.booking; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @Component public class BookingListeners { @EventListener @Order(1) public void sendTicket(SeatBooked event) { System.out.println("Ticket SMS sent for seat " + event.seat()); } @EventListener @Order(2) public void sendOffer(SeatBooked event) { System.out.println("Snack offer sent to " + event.customer()); } }
File: BookingApplication.java in package com.starplex.booking
javapackage com.starplex.booking; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @SpringBootApplication public class BookingApplication { public static void main(String[] args) { SpringApplication.run(BookingApplication.class, args); } @Bean CommandLineRunner demo(ApplicationEventPublisher publisher) { return args -> publisher.publishEvent(new SeatBooked("A5", "Rhea")); } }
Output:
textTicket SMS sent for seat A5 Snack offer sent to Rhea
Medium: Payment Chain and a Failing Listener
PayEasy publishes PaymentReceived(id, amount). The first listener prints a line and returns a ReceiptReady event, which Spring publishes automatically. A second listener must reject amounts above 10000 by throwing IllegalStateException("Fraud check offline"). A third listener writes an audit line. The publisher must catch the exception and print it. Show what happens for payments of 900 and 20000.
Show answerHide answer
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.payeasy</groupId> <artifactId>payments</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: PaymentReceived.java in package com.payeasy.payments
javapackage com.payeasy.payments; public record PaymentReceived(int id, int amount) {}
File: ReceiptReady.java in package com.payeasy.payments
javapackage com.payeasy.payments; public record ReceiptReady(int paymentId) {}
File: PaymentListeners.java in package com.payeasy.payments
javapackage com.payeasy.payments; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @Component public class PaymentListeners { @EventListener @Order(1) public ReceiptReady makeReceipt(PaymentReceived event) { System.out.println("Receipt made for payment " + event.id()); return new ReceiptReady(event.id()); } @EventListener @Order(2) public void fraudCheck(PaymentReceived event) { if (event.amount() > 10000) { throw new IllegalStateException("Fraud check offline"); } System.out.println("Fraud check passed for payment " + event.id()); } @EventListener @Order(3) public void audit(PaymentReceived event) { System.out.println("Audit line written for payment " + event.id()); } @EventListener public void onReceipt(ReceiptReady event) { System.out.println("Receipt event heard for " + event.paymentId()); } }
File: PaymentsApplication.java in package com.payeasy.payments
javapackage com.payeasy.payments; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @SpringBootApplication public class PaymentsApplication { public static void main(String[] args) { SpringApplication.run(PaymentsApplication.class, args); } @Bean CommandLineRunner demo(ApplicationEventPublisher publisher) { return args -> { publisher.publishEvent(new PaymentReceived(1, 900)); try { publisher.publishEvent(new PaymentReceived(2, 20000)); } catch (IllegalStateException e) { System.out.println("Publisher caught: " + e.getMessage()); } }; } }
Output:
textReceipt made for payment 1 Receipt event heard for 1 Fraud check passed for payment 1 Audit line written for payment 1 Receipt made for payment 2 Receipt event heard for 2 Publisher caught: Fraud check offline
The last line is one line on screen. It is wrapped here to fit small screens. Notice that the receipt event is heard right after the receipt listener returns, before the next listener runs.