Notification System
августа 20, 2026
Building a Notification System with Spring: Dependency Injection, Qualifiers, and Events
We are going to use these Maven dependencies:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>7.0.8</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>7.0.8</version>
<scope>compile</scope>
</dependency>
Setting up the domain
We start with the NotificationSender interface:
/**
* @author Konstantine Vashalomidze
*/
public interface NotificationSender {
void send(Notification notification);
}
And its implementations, EmailNotificationSender, PushNotificationSender, SmsNotificationSender:
/**
* @author Konstantine Vashalomidze
*/
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(Notification notification) {
System.out.println("[EMAIL] " + notification.text());
}
}
/**
* @author Konstantine Vashalomidze
*/
public class PushNotificationSender implements NotificationSender {
@Override
public void send(Notification notification) {
System.out.println("[PUSH] " + notification.text());
}
}
/**
* @author Konstantine Vashalomidze
*/
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(Notification notification) {
System.out.println("[SMS] " + notification.text());
}
}
A Notification interface is also introduced:
/**
* @author Konstantine Vashalomidze
*/
public interface Notification {
String text();
}
We implement it via records:
/**
* @author Konstantine Vashalomidze
*/
public record EmailNotification(String text) implements Notification {
}
NOTE: We will need records PushNotification and SmsNotification as well, in the same shape.
We need the actual notification service where we inject NotificationSender:
/**
* @author Konstantine Vashalomidze
*/
@Service
public class NotificationService {
private final NotificationSender notificationSender;
@Autowired
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
}
We create those beans in a configuration class:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class NotificationConfig {
@Bean
public EmailNotificationSender emailNotificationSender() {
return new EmailNotificationSender();
}
@Bean
public PushNotificationSender pushNotificationSender() {
return new PushNotificationSender();
}
@Bean
public SmsNotificationSender smsNotificationSender() {
return new SmsNotificationSender();
}
}
First run: nothing happens, then an ambiguity error
public class Main {
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(Main.class);
}
}
Nothing happens, since Spring doesn't scan other packages for configuration classes on its own. We need to either pass those configuration classes explicitly to AnnotationConfigApplicationContext, or annotate Main with @ComponentScan(basePackages = "com.github.konstantinevashalomidze"). Since Main.class itself is registered, and it now carries a recognizable annotation, Spring treats it as a valid entry point and scans from there:
@ComponentScan(basePackages = "com.github.konstantinevashalomidze")
public class Main {
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(Main.class);
}
}
Re-running now produces:
WARNING: Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'notificationService' defined in file
This is UnsatisfiedDependencyException, caused by injecting the NotificationSender interface into NotificationService while three separate beans all satisfy that type. Spring has no way to know which one is wanted.
Resolving ambiguity: @Primary
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class NotificationConfig {
@Primary
@Bean
public EmailNotificationSender emailNotificationSender() {
return new EmailNotificationSender();
}
// ...
}
@Primary tells Spring which bean to prefer when multiple candidates exist.
@Qualifier beats @Primary
@Qualifier("sms")
@Bean
public SmsNotificationSender smsNotificationSender() {
return new SmsNotificationSender();
}
The qualifier must also be specified at the injection point:
@Autowired
public NotificationService(@Qualifier("sms") NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
To prove @Qualifier actually overrides @Primary, invoke send() directly in the constructor:
@Autowired
public NotificationService(@Qualifier("sms") NotificationSender notificationSender) {
this.notificationSender = notificationSender;
notificationSender.send(new SmsNotification("something"));
}
Console:
[SMS] something
Confirming SmsNotificationSender was injected, despite EmailNotificationSender being marked @Primary.
@Fallback as an alternative
Remove @Primary from the email sender, and mark both push and SMS senders @Fallback:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class NotificationConfig {
@Bean
public EmailNotificationSender emailNotificationSender() {
return new EmailNotificationSender();
}
@Fallback
@Bean
public PushNotificationSender pushNotificationSender() {
return new PushNotificationSender();
}
@Fallback
@Bean
public SmsNotificationSender smsNotificationSender() {
return new SmsNotificationSender();
}
}
NOTE: @Qualifier annotations are removed from both places for this step.
EmailNotificationSender still resolves correctly, by elimination:
@Autowired
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
notificationSender.send(new EmailNotification("something"));
}
Console:
[EMAIL] something
@Fallback tells Spring to treat a bean as a last resort. If exactly one non-fallback bean remains among the candidates, it is selected automatically. However, if @Fallback is added to the email sender too - making all three candidates fallback beans - the app throws UnsatisfiedDependencyException again, since no single non-fallback bean remains to select.
Injecting all beans as a collection
To send a notification through every channel at once, we can ask Spring for a List of all matching beans instead of resolving a single one:
/**
* @author Konstantine Vashalomidze
*/
@Service
public class BroadcastService {
private final List<NotificationSender> notificationSenders;
@Autowired
public BroadcastService(List<NotificationSender> notificationSenders) {
this.notificationSenders = notificationSenders;
}
public void broadcastToAll(Notification notification) {
notificationSenders.forEach(notificationSender -> notificationSender.send(notification));
}
}
No List<NotificationSender> bean was ever declared - Spring automatically collects every matching bean into the collection. Calling broadcastToAll produces:
[EMAIL] Hello, Kosta!
[PUSH] Hello, Kosta!
[SMS] Hello, Kosta!
A custom qualifier annotation: @Channel
/**
* @author Konstantine Vashalomidze
*/
@Target({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Channel {
ChannelType value();
}
/**
* @author Konstantine Vashalomidze
*/
public enum ChannelType {
EMAIL,
SMS,
PUSH
}
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class NotificationConfig {
@Channel(ChannelType.EMAIL)
@Bean
public EmailNotificationSender emailNotificationSender() {
return new EmailNotificationSender();
}
@Channel(ChannelType.PUSH)
@Bean
public PushNotificationSender pushNotificationSender() {
return new PushNotificationSender();
}
@Channel(ChannelType.SMS)
@Bean
public SmsNotificationSender smsNotificationSender() {
return new SmsNotificationSender();
}
}
/**
* @author Konstantine Vashalomidze
*/
@Service
public class NotificationService {
private final NotificationSender notificationSender;
@Autowired
public NotificationService(@Channel(ChannelType.EMAIL) NotificationSender notificationSender) {
this.notificationSender = notificationSender;
notificationSender.send(new EmailNotification("email text"));
}
}
Console:
[EMAIL] Email sent
Why prefer a custom annotation over plain @Qualifier, when they look nearly identical here? The value only shows up around typos. Typing @Qualifier("emali") instead of @Qualifier("email") compiles cleanly and only fails once the app starts - possibly well after it's already been committed and pushed. Using an enum-backed custom annotation like @Channel(ChannelType.EMAIL) closes that gap entirely: @Channel(ChannelType.EMIAL) simply will not compile, caught immediately by the IDE.
It's worth being precise here: a string-valued custom qualifier offers no compile-time advantage over plain @Qualifier at all - both fail identically, only at runtime. The real safety comes specifically from backing the annotation's value with an enum instead of a raw String.
Extending @Channel with a second attribute
Notifications can be standard or urgent. Add a Priority enum:
/**
* @author Konstantine Vashalomidze
*/
public enum Priority {
STANDARD,
URGENT
}
Extend @Channel to require both attributes:
/**
* @author Konstantine Vashalomidze
*/
@Target({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Channel {
ChannelType value();
Priority priority();
}
Update the configuration to declare one bean per channel/priority combination:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class NotificationConfig {
@Channel(value = ChannelType.EMAIL, priority = Priority.STANDARD)
@Bean
public EmailNotificationSender emailNotificationSenderStandard() {
return new EmailNotificationSender(Priority.STANDARD);
}
@Channel(value = ChannelType.EMAIL, priority = Priority.URGENT)
@Bean
public EmailNotificationSender emailNotificationSenderUrgent() {
return new EmailNotificationSender(Priority.URGENT);
}
@Channel(value = ChannelType.PUSH, priority = Priority.STANDARD)
@Bean
public PushNotificationSender pushNotificationSenderStandard() {
return new PushNotificationSender(Priority.STANDARD);
}
@Channel(value = ChannelType.PUSH, priority = Priority.URGENT)
@Bean
public PushNotificationSender pushNotificationSenderUrgent() {
return new PushNotificationSender(Priority.URGENT);
}
@Channel(value = ChannelType.SMS, priority = Priority.STANDARD)
@Bean
public SmsNotificationSender smsNotificationStandard() {
return new SmsNotificationSender(Priority.STANDARD);
}
@Channel(value = ChannelType.SMS, priority = Priority.URGENT)
@Bean
public SmsNotificationSender smsNotificationUrgent() {
return new SmsNotificationSender(Priority.URGENT);
}
}
Update the senders to take a Priority label, so we can see which variant handled a given message:
/**
* @author Konstantine Vashalomidze
*/
public class EmailNotificationSender implements NotificationSender {
private final Priority label;
public EmailNotificationSender(Priority label) {
this.label = label;
}
@Override
public void send(Notification notification) {
System.out.printf("[%s][EMAIL] %s%n", label, notification);
}
}
NOTE: PushNotificationSender and SmsNotificationSender are updated the same way.
Update Notification to carry priority too:
/**
* @author Konstantine Vashalomidze
*/
public interface Notification {
String text();
String priority();
}
/**
* @author Konstantine Vashalomidze
*/
public record EmailNotification(String text, String priority) implements Notification {
}
NOTE: PushNotification and SmsNotification are updated the same way.
@Service
public class NotificationService {
private final NotificationSender notificationSender;
@Autowired
public NotificationService(@Channel(value = ChannelType.EMAIL, priority = Priority.STANDARD) NotificationSender notificationSender) {
this.notificationSender = notificationSender;
notificationSender.send(new EmailNotification("email text", Priority.STANDARD.name()));
}
}
Console:
[STANDARD][EMAIL] EmailNotification[text=email text, priority=STANDARD]
Spring correctly resolved the bean matching both attributes at once.
Storing notifications, and disambiguating by generics
We want to persist sent messages. A central generic interface lets us swap the storage mechanism later without touching callers:
/**
* @author Konstantine Vashalomidze
*/
public interface NotificationStore<T> {
void save(T item);
}
/**
* @author Konstantine Vashalomidze
*/
@Component
public class EmailStore implements NotificationStore<EmailNotification> {
@Override
public void save(EmailNotification item) {
System.out.printf("Storing email: %s%n", item.text());
}
}
NOTE: PushStore and SmsStore are implemented the same way, each parameterized with their own notification type.
Even though all three stores implement the same raw NotificationStore interface, Spring distinguishes them by their generic type parameter alone - no qualifier annotation needed at all.
Splitting into StandardNotificationService and UrgentNotificationService
/**
* @author Konstantine Vashalomidze
*/
@Service
public class StandardNotificationService {
private final NotificationSender emailNotificationSender;
private final NotificationSender pushNotificationSender;
private final NotificationSender smsNotificationSender;
private final NotificationStore<EmailNotification> emailStore;
private final NotificationStore<PushNotification> pushStore;
private final NotificationStore<SmsNotification> smsStore;
@Autowired
public StandardNotificationService(
@Channel(value = ChannelType.EMAIL, priority = Priority.STANDARD) NotificationSender emailNotificationSender,
@Channel(value = ChannelType.PUSH, priority = Priority.STANDARD) NotificationSender pushNotificationSender,
@Channel(value = ChannelType.SMS, priority = Priority.STANDARD) NotificationSender smsNotificationSender,
NotificationStore<EmailNotification> emailStore,
NotificationStore<PushNotification> pushStore,
NotificationStore<SmsNotification> smsStore
) {
this.emailNotificationSender = emailNotificationSender;
this.pushNotificationSender = pushNotificationSender;
this.smsNotificationSender = smsNotificationSender;
this.emailStore = emailStore;
this.pushStore = pushStore;
this.smsStore = smsStore;
sendEmail(new EmailNotification("Hello, from email", Priority.STANDARD.name()));
sendPush(new PushNotification("Hello, from push", Priority.STANDARD.name()));
sendSms(new SmsNotification("Hello, from sms", Priority.STANDARD.name()));
}
public void sendEmail(EmailNotification item) {
emailNotificationSender.send(item);
emailStore.save(item);
}
public void sendPush(PushNotification item) {
pushNotificationSender.send(item);
pushStore.save(item);
}
public void sendSms(SmsNotification item) {
smsNotificationSender.send(item);
smsStore.save(item);
}
}
NOTE: UrgentNotificationService is implemented identically, except each @Channel qualifier requests Priority.URGENT instead of Priority.STANDARD, and the sample messages sent from its constructor are tagged Priority.URGENT.name() as well.
Console:
[STANDARD][EMAIL] EmailNotification[text=Hello, from email, priority=STANDARD]
Storing email: Hello, from email
[STANDARD][PUSH] PushNotification[text=Hello, from push, priority=STANDARD]
Storing push: Hello, from push
[STANDARD][SMS] SmsNotification[text=Hello, from sms, priority=STANDARD]
Storing sms: Hello, from sms
Notice two different disambiguation mechanisms working side by side in one constructor: the three NotificationSender parameters are resolved by the custom @Channel qualifier (channel plus priority), while the three NotificationStore<T> parameters are resolved purely by their generic type parameter, with no qualifier at all.
Refactoring to an event-driven design
Right now, StandardNotificationService directly calls each NotificationSender and each NotificationStore itself. That works, but it means the service has to know about every channel that exists. Adding a fourth channel later would mean going back into this class and adding more constructor parameters and more method calls. Let's restructure this using Spring's event system instead, so that requesting a notification and delivering it become two separate, decoupled concerns.
First, the event itself - a plain object, no special base class required:
/**
* @author Konstantine Vashalomidze
*/
public record NotificationRequested(Notification notification, ChannelType channel) {
}
Now StandardNotificationService shrinks down to just publishing this event, instead of doing any sending itself:
/**
* @author Konstantine Vashalomidze
*/
@Service
public class StandardNotificationService {
private final ApplicationEventPublisher publisher;
@Autowired
public StandardNotificationService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
sendEmail(new EmailNotification("Hello, from email", Priority.STANDARD.name()));
sendPush(new PushNotification("Hello, from push", Priority.STANDARD.name()));
sendSms(new SmsNotification("Hello, from sms", Priority.STANDARD.name()));
}
public void sendEmail(EmailNotification item) {
publisher.publishEvent(new NotificationRequested(item, ChannelType.EMAIL));
}
public void sendPush(PushNotification item) {
publisher.publishEvent(new NotificationRequested(item, ChannelType.PUSH));
}
public void sendSms(SmsNotification item) {
publisher.publishEvent(new NotificationRequested(item, ChannelType.SMS));
}
}
This class no longer knows anything about NotificationSender or NotificationStore at all - it only knows how to announce that a notification was requested.
The actual sending and storing moves into a listener, one per channel. Each listener uses @EventListener's condition attribute to only react to its own channel:
/**
* @author Konstantine Vashalomidze
*/
@Component
public class EmailDeliveryListener {
private final NotificationSender emailSender;
private final NotificationStore<EmailNotification> emailStore;
@Autowired
public EmailDeliveryListener(
@Channel(value = ChannelType.EMAIL, priority = Priority.STANDARD) NotificationSender emailSender,
NotificationStore<EmailNotification> emailStore) {
this.emailSender = emailSender;
this.emailStore = emailStore;
}
@EventListener(condition = "#event.channel() == T(com.github.konstantinevashalomidze.ChannelType).EMAIL")
public void onNotificationRequested(NotificationRequested event) {
EmailNotification email = (EmailNotification) event.notification();
emailSender.send(email);
emailStore.save(email);
}
}
NOTE: PushDeliveryListener and SmsDeliveryListener are implemented the same way, each filtering on their own ChannelType. As written, these listeners are wired to the STANDARD-priority senders specifically; wiring up the URGENT path for UrgentNotificationService as well would need either a second, priority-aware set of listeners, or folding priority into the NotificationRequested event itself and branching on it inside a single listener - left as a natural next refinement rather than shown here.
Separately, an audit listener can react to the exact same event, with no knowledge of delivery at all:
/**
* @author Konstantine Vashalomidze
*/
@Component
public class AuditLogListener {
@EventListener
@Order(1)
public void logRequest(NotificationRequested event) {
System.out.printf("[AUDIT] Requested %s notification: %s%n", event.channel(), event.notification());
}
}
@Order(1) ensures the audit log fires before the channel-specific delivery listeners, since all listeners here are synchronous by default and run in a predictable, declared order.
Running the application now, StandardNotificationService's constructor fires three publishEvent() calls, and Spring dispatches each to exactly one matching delivery listener plus the audit listener:
[AUDIT] Requested EMAIL notification: EmailNotification[text=Hello, from email, priority=STANDARD]
[STANDARD][EMAIL] EmailNotification[text=Hello, from email, priority=STANDARD]
Storing email: Hello, from email
[AUDIT] Requested PUSH notification: PushNotification[text=Hello, from push, priority=STANDARD]
[STANDARD][PUSH] PushNotification[text=Hello, from push, priority=STANDARD]
Storing push: Hello, from push
[AUDIT] Requested SMS notification: SmsNotification[text=Hello, from sms, priority=STANDARD]
[STANDARD][SMS] SmsNotification[text=Hello, from sms, priority=STANDARD]
Storing sms: Hello, from sms
The real payoff of this design shows up the moment a fourth channel needs to be added: it requires writing one new listener class - say, SlackDeliveryListener, filtering on ChannelType.SLACK - with zero changes to StandardNotificationService itself. The publisher never needs to know how many listeners exist or what they do.
Making a slow listener non-blocking with @Async
Since event listeners are synchronous by default, publishEvent() blocks until every matching listener finishes - including the audit listener and the channel-specific delivery listener. To see this matter concretely, imagine a slow, non-critical listener - say, forwarding the notification to an external analytics service over the network:
/**
* @author Konstantine Vashalomidze
*/
@Component
public class AnalyticsListener {
@Async
@EventListener
public void onNotificationRequested(NotificationRequested event) {
try {
Thread.sleep(1000); // simulating a slow network call
} catch (InterruptedException ignored) {}
System.out.println("[ANALYTICS] Logged (after a 1s delay): " + event.notification());
}
}
For @Async to take effect, @EnableAsync must be added somewhere in the configuration:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
@EnableAsync
public class AsyncConfig {
}
With this in place, StandardNotificationService's constructor returns immediately after publishing all three events - it does not wait a combined three seconds for AnalyticsListener to finish three times over. The [ANALYTICS] lines appear roughly a second later, interleaved with whatever runs afterward, proving the async listener genuinely runs off to the side instead of blocking the publisher.
Chaining events: confirming delivery
EmailDeliveryListener currently just sends and stores. A real system also wants to know a delivery succeeded, independently of the sending logic itself - useful for a future retry mechanism, or simply a delivery log. Instead of manually injecting ApplicationEventPublisher into the listener, an @EventListener method can just return a new event, and Spring publishes it automatically:
/**
* @author Konstantine Vashalomidze
*/
public record NotificationDelivered(ChannelType channel, String text) {
}
@EventListener(condition = "#event.channel() == T(com.github.konstantinevashalomidze.ChannelType).EMAIL")
public NotificationDelivered onNotificationRequested(NotificationRequested event) {
EmailNotification email = (EmailNotification) event.notification();
emailSender.send(email);
emailStore.save(email);
return new NotificationDelivered(ChannelType.EMAIL, email.text());
}
A separate listener can react to delivery confirmations, with no knowledge of how delivery happened:
/**
* @author Konstantine Vashalomidze
*/
@Component
public class DeliveryConfirmationListener {
@EventListener
public void onDelivered(NotificationDelivered event) {
System.out.println("[CONFIRMED] " + event.channel() + " delivery complete: " + event.text());
}
}
Console, appended after the existing [STANDARD][EMAIL] / Storing email lines:
[CONFIRMED] EMAIL delivery complete: Hello, from email
Note that this chaining only works for synchronous listeners - AnalyticsListener, being @Async, could not use this mechanism even if it wanted to publish a follow-up event this way.
@Resource: resolving by name instead of by type
Every injection point so far has used @Autowired, which resolves by type first and narrows by qualifier second. @Resource inverts that priority - it resolves by name first. To see the difference directly, EmailDeliveryListener can request the exact bean named emailNotificationSenderStandard (the method name used back when the @Channel-qualified beans were declared) using @Resource instead of @Autowired plus @Channel:
@Resource(name = "emailNotificationSenderStandard")
private NotificationSender emailSender;
This resolves to the same bean as the @Channel-qualified constructor parameter did - proving @Resource and @Autowired+@Qualifier can reach the same destination through genuinely different resolution logic. @Resource is the more honest choice specifically when the real intent is "give me this exact bean by name," rather than "give me something matching this type, narrowed by a qualifier."
@Order: controlling broadcast sequence
BroadcastService sends through all three channels, but in whatever order Spring happened to register the beans - not something worth relying on. Adding @Order to each sender bean makes the sequence explicit:
@Order(1)
@Channel(value = ChannelType.EMAIL, priority = Priority.STANDARD)
@Bean
public EmailNotificationSender emailNotificationSenderStandard() { ... }
@Order(2)
@Channel(value = ChannelType.SMS, priority = Priority.STANDARD)
@Bean
public SmsNotificationSender smsNotificationStandard() { ... }
@Order(3)
@Channel(value = ChannelType.PUSH, priority = Priority.STANDARD)
@Bean
public PushNotificationSender pushNotificationSenderStandard() { ... }
With this in place, BroadcastService's List<NotificationSender> is guaranteed to iterate email, then SMS, then push - regardless of how the beans were declared in the configuration class. This @Order is unrelated to the @Order used on AuditLogListener earlier - the same annotation, controlling two entirely different collections in two entirely different parts of the system.
Constructor selection: two ways to build the same service
Spring can choose between multiple constructors on the same class, picking whichever one it can fully satisfy with the most dependencies. StandardNotificationService can offer a minimal constructor alongside the full one, useful for testing without a real ApplicationEventPublisher:
@Autowired(required = false)
public StandardNotificationService() {
this.publisher = null; // a no-op variant, mainly useful in isolated unit tests
}
@Autowired(required = false)
public StandardNotificationService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
// ... send the three sample notifications as before
}
Both constructors are marked required = false because more than one is @Autowired - the rule that exactly one constructor may be required = true (or unmarked) applies the moment a second @Autowired constructor appears. With a real ApplicationEventPublisher bean available in the container, Spring picks the constructor with the most dependencies it can satisfy - the two-argument one - and the no-arg constructor never actually runs in the full application, only in a test that builds StandardNotificationService directly without a container.
Localizing notification text with MessageSource
Every notification so far has hardcoded English text. A real notification system sends different languages to different users. A messageSource bean - the name is required, not incidental - unlocks this:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class MessageConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasenames("notifications");
source.setDefaultEncoding("UTF-8");
return source;
}
}
notifications.properties:
email.greeting=Hello, from email
notifications_en_GB.properties:
email.greeting=Alright then, from email
StandardNotificationService resolves the greeting instead of hardcoding it:
@Autowired
public StandardNotificationService(ApplicationEventPublisher publisher, MessageSource messageSource) {
this.publisher = publisher;
String greeting = messageSource.getMessage("email.greeting", null, Locale.US);
sendEmail(new EmailNotification(greeting, Priority.STANDARD.name()));
// ...
}
Console (using Locale.US, which falls through to the base file since no _en_US variant exists):
[STANDARD][EMAIL] EmailNotification[text=Hello, from email, priority=STANDARD]
Switching to Locale.UK would pick up the _en_GB file instead, since Locale.UK.toString() is exactly "en_GB" - the file suffix and the locale's own string form match directly, nothing to look up separately.
Loading an email template with Resource
Real email senders typically pull a template from disk rather than hardcoding a string in Java. EmailNotificationSender can accept an injected Resource pointing at a template file:
public class EmailNotificationSender implements NotificationSender {
private final Priority label;
private final Resource template;
public EmailNotificationSender(Priority label, Resource template) {
this.label = label;
this.template = template;
}
@Override
public void send(Notification notification) {
try (var reader = new BufferedReader(new InputStreamReader(template.getInputStream()))) {
String templateText = reader.readLine();
System.out.printf("[%s][EMAIL] (%s) %s%n", label, templateText, notification);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
The bean declaration supplies the template as a plain classpath string, auto-converted into a Resource:
@Bean
public EmailNotificationSender emailNotificationSenderStandard(
@Value("classpath:email-template.txt") Resource template) {
return new EmailNotificationSender(Priority.STANDARD, template);
}
getInputStream() is used rather than getFile() deliberately - once this project is packaged into a jar, the template lives inside the jar rather than as a standalone file, and only getInputStream() is guaranteed to still work in that case.
Swapping senders by environment with @Profile
Every sender so far prints to the console, which is fine for development but not something anyone wants running against real users. A dev profile can keep the console senders; a prod profile can swap in senders that would talk to real providers:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
@Profile("dev")
public class DevNotificationConfig {
@Bean
public EmailNotificationSender emailNotificationSenderStandard() {
return new EmailNotificationSender(Priority.STANDARD); // prints to console
}
// push, sms the same way
}
/**
* @author Konstantine Vashalomidze
*/
@Configuration
@Profile("prod")
public class ProdNotificationConfig {
@Bean
public EmailNotificationSender emailNotificationSenderStandard() {
// in a real system, this would wrap an actual email provider client
return new EmailNotificationSender(Priority.STANDARD);
}
// push, sms the same way
}
With spring.profiles.active=dev set in application.properties (or passed as -Dspring.profiles.active=prod on the command line, which takes precedence over the properties file), only one of the two configuration classes is ever processed - the other is skipped entirely, as if it were never written. With no profile active at all, neither registers, and the application fails to start with NoSuchBeanDefinitionException - profiles must be explicitly activated, tagging alone does nothing.
A debug-only sender behind a custom @Conditional
Beyond dev/prod, a genuinely optional debug sender - one that logs every notification with full internal detail, useful only when actively investigating an issue - is a natural fit for a custom condition rather than a profile:
/**
* @author Konstantine Vashalomidze
*/
public class DebugModeCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return "true".equals(context.getEnvironment().getProperty("notifications.debug"));
}
}
@Bean
@Conditional(DebugModeCondition.class)
public DebugNotificationListener debugNotificationListener() {
return new DebugNotificationListener();
}
public class DebugNotificationListener {
@EventListener
public void onRequested(NotificationRequested event) {
System.out.println("[DEBUG] Full event: " + event);
}
}
With notifications.debug=true set in application.properties, this bean registers and its listener fires alongside every other listener reacting to NotificationRequested. With the property absent or false, the bean is never registered at all - not present but disabled, genuinely absent from the container.
Managing a connection's lifecycle
A real SMS provider typically requires establishing a connection before sending anything, and cleanly closing it on shutdown. A fake SmsGatewayConnection makes this concrete:
/**
* @author Konstantine Vashalomidze
*/
public class SmsGatewayConnection {
public SmsGatewayConnection() {
System.out.println("[SMS GATEWAY] connecting...");
}
public void close() {
System.out.println("[SMS GATEWAY] disconnected");
}
}
@Bean
public SmsGatewayConnection smsGatewayConnection() {
return new SmsGatewayConnection();
}
Because this is a @Bean-declared object with a public close() method, Spring calls it automatically on shutdown - no @PreDestroy, no explicit destroyMethod needed.
If sending SMS genuinely must not happen before the gateway is ready, and the connection itself needs to be the last thing to shut down (after the SMS sender itself has stopped sending), SmartLifecycle expresses that ordering directly, independent of when the beans were constructed:
public class SmsGatewayConnection implements SmartLifecycle {
private volatile boolean running = false;
@Override
public void start() {
System.out.println("[SMS GATEWAY] connection established");
running = true;
}
@Override
public void stop() {
System.out.println("[SMS GATEWAY] connection closing");
running = false;
}
@Override
public boolean isRunning() { return running; }
@Override
public int getPhase() { return Integer.MIN_VALUE; } // starts first, stops last
}
A low phase number means this bean starts before other SmartLifecycle beans and stops after them - guaranteeing the gateway is live before anything tries to use it, and stays live until everything else relying on it has finished.
Watching every bean get created
With this many senders, listeners, and configuration classes now in play, a simple BeanPostProcessor gives visibility into what the container actually built at startup, without touching any of the existing code:
/**
* @author Konstantine Vashalomidze
*/
@Configuration
public class LoggingConfig {
@Bean
public static BeanPostProcessor startupLogger() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
System.out.println("[STARTUP] created bean: " + beanName + " (" + bean.getClass().getSimpleName() + ")");
return bean;
}
};
}
}
The method is static deliberately - it needs to exist before the rest of the container's normal bean lifecycle is ready, and a non-static method here would tie its creation to LoggingConfig's own instance being built first, which defeats the purpose of a post-processor meant to observe everything, including beans that might otherwise be created earlier in the startup sequence.
Conclusion
What started as three notification senders behind one ambiguous interface grew into a small but genuinely representative Spring application: qualified and generic dependency injection, an event-driven core replacing direct method calls, environment-specific configuration, lifecycle-managed infrastructure, and startup-time observability - all motivated by real requirements a notification system would actually have, not bolted on for their own sake.