Demo: pluggable notification channels
On This Lesson 11 topics
Lesson Overview
This is a follow-along demo that puts the whole module together. You will build a Notifier interface with three implementations (console, email, SMS), write a Bank that sends statements through any of them, make one deliberate mistake that ties the bank to a single channel, and fix it. Think of the wall socket from this module: the bank is the wall, each notifier is a plug.
The demo also rehearses this module's lab, "Notification channels". If you can follow all six steps with the code in front of you, then repeat them from an empty file and finish with your own variations, the lab is within reach. The lab's acceptance rule is that the bank class depends only on the interface, and you will check that rule yourself in the last step.
Why this matters: a bank that cannot change how it contacts customers without being edited is a bank that breaks on every product change. The same pattern is how real backend services keep their parts independent.
This lesson uses everything from lessons 1 to 5 of the module. Menus and shortcuts differ between IDEs and operating systems, so the steps below are described by action ("compile and run"), not by clicks. A terminal with javac and java, or any IDE, will do.
Learning Objectives
By the end of this lesson you will be able to:
- Declare a small, focused
Notifierinterface. - Implement it in three classes and compile each against the contract.
- Write a
Bankthat holds only the interface type and receives it by constructor. - Run the same statement through all three channels.
- Reproduce and read the compiler error caused by a class-typed field.
- Check that
Bankmentions no implementation name.
Written Explanation
The plan
Do the steps in order and compile after each one.
| Step | Action | What you should see |
|---|---|---|
| 1 | Declare Notifier with send(String to, String message) | Compiles, nothing to run yet |
| 2 | Implement ConsoleNotifier, then EmailNotifier and SmsNotifier | Each compiles |
| 3 | Write Bank with a Notifier field set in the constructor | Compiles |
| 4 | Write Main, call sendStatement with each channel | Three lines of output |
| 5 | Planted mistake: change the Bank field to EmailNotifier | Compiler error in Main |
| 6 | Restore the interface type and check that Bank never names a channel | Back to three lines |
The lesson's code lives in one folder with one file per class. Files are named after the public class they contain.
Rules for a safe build
- Compile after every step. A small change that breaks is easy to find.
- Start with the interface from the caller's point of view. Ask what the bank needs: "send this text to this person". That is one method, so the interface has one method.
- Do not add anything the bank does not need yet. No
setSubject, noretry.
Examples
Step 1: The interface
public interface Notifier {
void send(String to, String message);
}One method, one purpose. This is the small, focused contract from lesson 1, not a "god" interface.
Step 2: Three implementations
public class ConsoleNotifier implements Notifier {
@Override
public void send(String to, String message) {
System.out.println("[console] " + to + ": " + message);
}
}public class EmailNotifier implements Notifier {
@Override
public void send(String to, String message) {
System.out.println("[email] To: " + to + " | Subject: Statement | Body: " + message);
}
}public class SmsNotifier implements Notifier {
@Override
public void send(String to, String message) {
System.out.println("[sms] " + to + " <- " + message);
}
}Printing stands in for a real email or SMS service. A real implementation would call that service inside send, and nothing else in the program would need to change. Note public on each method, required because interface methods are public.
Step 3: The bank depends on the contract
public class Bank {
private final Notifier notifier;
public Bank(Notifier notifier) {
this.notifier = notifier;
}
public void sendStatement(String customer, String contact, double balance) {
notifier.send(contact, "Dear " + customer + ", your balance is " + balance);
}
}Bank has no new and no channel name. It is given a notifier and uses it. The type of the field, the constructor parameter, and the call are all Notifier.
Step 4: Run the same statement through all channels
public class Main {
public static void main(String[] args) {
Notifier[] channels = {
new ConsoleNotifier(), new EmailNotifier(), new SmsNotifier()
};
for (Notifier channel : channels) {
Bank bank = new Bank(channel);
bank.sendStatement("Ana", "ana@example.com", 150.0);
}
}
}Compile all .java files and run Main. The output (JDK 21):
[console] ana@example.com: Dear Ana, your balance is 150.0
[email] To: ana@example.com | Subject: Statement | Body: Dear Ana, your balance is 150.0
[sms] ana@example.com <- Dear Ana, your balance is 150.0The same bank code produced three different behaviors. Look at the third line: a real SMS needs a phone number, not an email address. Here the to value is just a String that every channel interprets for itself, which is a simplification of the demo. A richer design could pass a customer object instead; that is for a later module.
Main is the one place that names the concrete classes. That matches the rule from the last lesson: choose the implementation in one place.
Step 5: The planted mistake
Now imagine a tired developer in a hurry. Email is the only channel today, so they write:
public class Bank {
private final EmailNotifier notifier; // the mistake: a class, not the interface
public Bank(EmailNotifier notifier) { this.notifier = notifier; }
...
}Everything still compiles in Bank. The failure shows up when Main tries to give the bank any channel:
Main.java:5: error: incompatible types: Notifier cannot be converted to EmailNotifier
Bank bank = new Bank(channel);
^
1 errorIf you change Main to pass new SmsNotifier() directly, the message names SmsNotifier instead. Either way, the lesson is the same: the bank has been welded to email, and supporting SMS now means editing Bank. That is the cost of depending on a class.
Notice what the mistake did not break: the code compiled in Bank itself. Only the caller noticed. In a large program, those callers may live in other files, other teams, or tests, and the failure appears far from the cause.
Step 6: Restore and verify
Change the field and constructor back to Notifier. Compile and run: the three lines return.
Then check the lab's rule: the bank class depends only on the interface. Open Bank.java and look for the words ConsoleNotifier, EmailNotifier, SmsNotifier. You can use your editor's search or a text-search command in your terminal (the command differs by operating system). They must not appear anywhere in the file, and there must be no new of any notifier. In the correct version, the only mention of Notifier is the interface type in the field, the constructor, and the call.
What you gained
| Without the interface | With the interface |
|---|---|
Bank is edited for each channel | Bank never changes |
| A new channel touches existing code | A new channel is a new class |
| Tests must use real email | Tests pass a fake notifier |
Bank knows details of each channel | Bank knows only send |
Where it stops
The demo hides things a real system has: failures (what if sending fails?), different address formats, and retries. Each would be a design question of its own. The interface is only the first step.
Backend Insight
The pattern you built is the usual shape of a backend service: a business class (Bank) that decides what should happen, and an interface that hides how it is carried out (Notifier). Production code replaces printing with a call to an email service or SMS provider, still behind the same interface. In a framework such as Spring, the framework creates the right implementation and gives it to the bank's constructor, much as Main did here.
When you review a service, ask: can I add a new channel by writing one new class and changing one line where the program starts? If yes, the design is healthy. If you have to open the business class, something is tied to a concrete type.
Common Mistakes
Mistake 1: A class-typed field or parameter in the bank
This is the planted mistake from step 5. The remedy is always the same: use the interface type.
Mistake 2: Creating the notifier inside Bank
- Incorrect:
this.notifier = new EmailNotifier();in the constructor. - Correct: take the
Notifieras a constructor parameter.
Mistake 3: Forgetting public on the implementing method
The compiler says it cannot implement the interface method because of weaker access privileges. Interface methods are public, so the implementation must be public as well.
Mistake 4: Putting channel-specific methods into the interface
Adding setSubject to Notifier because email needs it forces console and SMS to write a method they cannot use. If only email needs a subject, give EmailNotifier that detail in its constructor or fields.
Mistake 5: Skipping the check in step 6
Looking at the file and finding no implementation name is the acceptance test of the lab. Do it every time.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. In the working version, which class or file is allowed to contain the words EmailNotifier and SmsNotifier in a new expression?
Show solution A1
A1. Only Main (the place where the program is assembled).
A2. What is the type of the Bank field in the correct version?
Show solution A2
A2. Notifier.
A3. Why does EmailNotifier.send need the word public?
Show solution A3
A3. Interface methods are public, so an implementation with less access (package-private) is rejected with a "weaker access privileges" error.
Level B: Application
B1. Add a fourth channel, PushNotifier, that prints [push] <to>: <message>. List exactly which existing files you must edit to get it used in the loop in Main.
Show solution B1
B1. Add the new file PushNotifier.java, and edit Main to add new PushNotifier() to the array. Bank.java and Notifier.java are not touched, which is the point.
B2. Write a RecordingNotifier that stores the last message in a field, and show a three-line check in Main that the bank sends exactly the text Dear Ana, your balance is 150.0.
Show solution B2
B2.
public class RecordingNotifier implements Notifier {
public String last;
@Override public void send(String to, String message) { last = message; }
}
// In Main:
RecordingNotifier rec = new RecordingNotifier();
new Bank(rec).sendStatement("Ana", "ana@example.com", 150.0);
System.out.println(rec.last.equals("Dear Ana, your balance is 150.0")); // true(Compiled and run against the demo files: it printed true.)
B3. In the planted mistake, Main fails to compile. Explain why it is better when the compiler fails early, compared to code that fails only at run time.
Show solution B3
B3. The compiler reports the problem before the program runs, with a file and line, so the developer sees it in minutes. A run-time failure may happen only for one customer in production, with a much harder trail.
Level C: Challenge
C1. Bank needs a second method sendAlert(String contact, String text) that sends a short alert through the same notifier. Should the Notifier interface change? Explain.
Show solution C1
C1. No. sendAlert can call the same notifier.send(contact, text). The interface only needs to change if the notifier's job changes (for example if alerts need a priority). Do not add methods just because the bank has another method.
C2. The product owner wants to send the statement through all three channels at once. Design a class MultiNotifier that implements Notifier and forwards each call to a group of other notifiers. Does Bank change?
Show solution C2
C2. MultiNotifier implements Notifier, holds an array of Notifier, and in send loops over it, calling send on each. Bank does not change, because it still holds one Notifier. This works because the interface is small and MultiNotifier is just another plug.
Mini Challenge
Without looking at the lesson, rebuild the demo from an empty folder and extend it:
- The
Notifierinterface and three implementations. - A
Bankthat holds only the interface and hassendStatement. - A
Mainthat runs the same statement through all three channels. - One extra: make
SmsNotifierrefuse (print a warning, send nothing) whentocontains no digits. - Prove the lab rule: show that
Bank.javacontains none of the channel names and nonew.
Check: adding the SMS rule touched only SmsNotifier.java.
Summary & Next Lesson
Key takeaways
- A small
Notifierinterface let oneBankwork with console, email, and SMS. - The bank holds the interface type, receives it by constructor, and never names an implementation.
Mainis the one place that chooses concrete classes.- Depending on a class instead of the interface (
EmailNotifier) ties the bank to one channel and forces edits for every new one. - The lab's acceptance test is simple to check:
Bank.javacontains no implementation name. - Interfaces keep a design open to change, but real systems still need decisions about failures and address formats.
Next lesson
This lesson ends the module "Abstract classes and interfaces". The next module is "Relationships" (BEG-025), which begins with "How objects know each other": how one object holds or uses another, which you have already done by giving Bank its Notifier.
Self-check
- Which class names the concrete notifiers in the final design?
- What is the type of
Bank's field? - What error appears when the field is declared as
EmailNotifierandMainpasses aNotifier? - What must you write to add a push channel, and which files stay unchanged?
- How do you check the lab's acceptance rule?
Show answers
Main, the place where the program is assembled.Notifier, the interface.incompatible types: Notifier cannot be converted to EmailNotifier.- A new class
PushNotifier implements Notifier, and one entry inMain.BankandNotifierstay unchanged. - Search
Bank.javafor the names of the implementations and fornew. None should appear.
References
- Oracle, The Java Tutorials: Defining an Interface. Supports interfaces as contracts and the idea that clients code against an interface without knowing the implementation.
- Oracle, Java Language Specification SE 21, Chapter 9: Interfaces. Supports the rule that interface methods are implicitly public.
- All code, output, and the compiler error shown were produced by compiling and running the demo files with JDK 21. The
RecordingNotifiersolution was compiled and run; theMultiNotifierdescription was not compiled.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.