البرمجة نحو واجهة
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
You now know how to write interfaces. This lesson is about using them well, and it comes down to one habit: write the code that uses something against the contract, not against a particular class. In wall-socket terms: the wall is built to the socket standard, not to one lamp. You can swap the lamp without rewiring the wall.
The phrase "program to an interface" does not mean "write the keyword interface everywhere". It means that variables, fields, and parameters should be declared with the interface type, and that the choice of the concrete class is made in one place.
Why this matters: this habit is what makes backend code testable and changeable. A class that talks to the contract can be run with a fake in a unit test, switched to another provider with a one-line change, and understood without reading the implementation.
This lesson builds on interfaces, abstract classes, and default methods from lessons 1 to 4, and on the Bank and Account examples used since the object-oriented modules.
Learning Objectives
By the end of this lesson you will be able to:
- State the rule: declare variables, fields, and parameters with the interface type.
- Show what breaks when a class depends on a concrete implementation.
- Pass a dependency into a class through its constructor.
- Swap implementations without editing the class that uses them.
- Use a small fake implementation to test a class.
- Decide where the concrete class should be chosen.
- Recognize the rule in standard-library code.
Written Explanation
The problem: depending on a class
class BadBank {
private final EmailNotifier notifier;
BadBank(EmailNotifier n) { this.notifier = n; }
void sendStatement(String customer, double balance) {
notifier.send(customer, "Balance: " + balance);
}
}BadBank works, but it is married to email. If the product needs SMS, the compiler stops you:
error: incompatible types: SmsNotifier cannot be converted to EmailNotifierThe only way to support SMS is to edit BadBank. Every new channel would mean another edit, in a class that has nothing to do with channels.
The fix: depend on the contract
class Bank {
private final Notifier notifier; // the interface type
Bank(Notifier notifier) { this.notifier = notifier; }
void sendStatement(String customer, double balance) {
notifier.send(customer, "Balance: " + balance);
}
}Three small changes made the difference: the field type is Notifier, the constructor parameter is Notifier, and the call is the same. Bank no longer mentions email, SMS, or console. It knows only the contract.
Passing the dependency in
Bank does not create its notifier with new. It receives one through the constructor. The caller decides:
Bank bank = new Bank(new EmailNotifier());Handing a class what it needs, instead of letting it build its own, is often called dependency injection. You do not need a framework for it: a constructor parameter is enough. (Frameworks such as Spring automate the same idea later in the roadmap.)
One place to choose
Choose the concrete class in one spot, usually at the start of the program (main or a setup class). Everywhere else, use the interface type. Then a change from email to SMS is a one-line edit in that one spot, and the rest of the program is untouched.
Fakes for testing
Because Bank accepts any Notifier, a test can pass a tiny recording class:
class RecordingNotifier implements Notifier {
final StringBuilder log = new StringBuilder();
@Override public void send(String to, String message) {
log.append(to).append('|').append(message).append(';');
}
}The test runs sendStatement and checks what was recorded. No real email is sent, and the test is fast and repeatable. This is a major reason to prefer the interface, and later testing modules build on it.
What to declare as what
| Where | Use |
|---|---|
| Field of a class that uses a service | The interface: Notifier |
| Method parameter or return type | The interface, unless callers truly need the class |
The new expression | The concrete class: new EmailNotifier() |
A local variable holding the result of new | Usually the interface (Notifier n = new EmailNotifier();) |
The same habit in the standard library
You will meet the collections library later in the course, but you can read this line already: List<String> names = new ArrayList<>();. The variable type is the interface List, and ArrayList appears only after new. If the project later wants a different list implementation, only the right-hand side changes. (This is a preview: you do not need to know List yet.)
When not to use an interface
Do not invent an interface for everything. A class that is a plain value holder (an address, a money amount) or that will certainly never have a second implementation does not need one. The cases where the habit pays off are boundaries: things that talk to the outside world (messages, storage, other services) or that you want to replace in tests.
Where the analogy stops
A wall does not choose its own plug, but a program has to pick a concrete class somewhere. That "somewhere" cannot be avoided. The aim is only to keep it in one place.
Examples
Example 1: The same Bank with three channels (simple)
class Bank {
private final Notifier notifier;
Bank(Notifier notifier) { this.notifier = notifier; }
void sendStatement(String customer, double balance) {
notifier.send(customer, "Balance: " + balance);
}
}With the classes EmailNotifier and SmsNotifier printing [email] ... and [sms] ...:
new Bank(new EmailNotifier()).sendStatement("ana@example.com", 150.0);
new Bank(new SmsNotifier()).sendStatement("555-0100", 150.0);[email] ana@example.com: Balance: 150.0
[sms] 555-0100: Balance: 150.0Bank was not edited between the two lines.
Example 2: Testing with a fake (practical)
RecordingNotifier fake = new RecordingNotifier();
new Bank(fake).sendStatement("test", 10.0);
System.out.println(fake.log);test|Balance: 10.0;The output shows exactly what Bank asked the notifier to send. An automated test would compare this string with the expected one.
Example 3: Choosing the implementation in one place (realistic)
public class Main {
public static void main(String[] args) {
Notifier channel = new EmailNotifier(); // the ONE place to change
Bank bank = new Bank(channel);
bank.sendStatement("ana@example.com", 150.0);
}
}Later a larger program has many classes that use notifiers. If each one calls new EmailNotifier() itself, switching to SMS means finding and editing all of them, and missing one leaves the program half switched. With the choice in main, the switch is one line, and every class still holds only the interface type. (All three examples were compiled and run with JDK 21.)
Backend Insight
In a Spring application the service classes receive repositories and clients through their constructors, and Spring picks the implementation. Your service code only mentions the interface, so the same service runs against a real database in production and a fake in tests. The rule you practise here by hand, receive an interface and do not build the dependency yourself, is the mental model for that whole style of application.
A useful review question for any class: what does it need from the outside world, and is each of those things an interface passed in? If a service builds its own database connection with new inside a method, it is hard to test and hard to move.
Common Mistakes
Mistake 1: Declaring the field with the class type
- Incorrect:
private final EmailNotifier notifier; - Correct:
private final Notifier notifier;
Mistake 2: Creating the dependency inside the class
- Incorrect: a
Bankconstructor that saysthis.notifier = new EmailNotifier(); - Correct: take a
Notifierparameter and assign it.
The first form hard-wires email even though the field type is an interface.
Mistake 3: Downcasting back to the class
If a method receives a Notifier and then casts it to EmailNotifier to use an email-only method, the dependence on the class is back. If the caller needs that method, it belongs in the interface, or in a separate interface.
Mistake 4: An interface for everything
Creating AddressInterface for a simple data class adds files and no flexibility. Use interfaces at boundaries and where you will really need a replacement.
Mistake 5: Importing or mentioning the implementation in the using class
If Bank has import ...EmailNotifier; or the name EmailNotifier anywhere, the contract is leaking. Check this: the using class should not mention any implementation.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. What type should a field have if the class only needs the contract: the interface or one concrete class?
أظهر الحل A1
A1. The interface.
A2. In List<String> names = new ArrayList<>();, which side shows the interface and which shows the class?
أظهر الحل A2
A2. Left: the interface List. Right: the class ArrayList.
A3. Name one benefit of passing the notifier into the Bank constructor.
أظهر الحل A3
A3. Any of: the class can be used with any channel; it can be tested with a fake; swapping channels needs no edit of Bank.
Level B: Application
B1. Given interface Payment { boolean pay(double amount); } and a class Checkout that has a field CardPayment payment, rewrite Checkout so that it works with any Payment. Show the constructor.
أظهر الحل B1
B1.
class Checkout {
private final Payment payment;
Checkout(Payment payment) { this.payment = payment; }
boolean buy(double amount) { return payment.pay(amount); }
}B2. Write a FakePayment that implements Payment, always returns true, and counts how many times it was called. Show how you would use it to check that Checkout.buy(...) calls it once.
أظهر الحل B2
B2.
class FakePayment implements Payment {
int calls = 0;
@Override public boolean pay(double amount) { calls++; return true; }
}
// FakePayment fake = new FakePayment();
// new Checkout(fake).buy(20.0);
// fake.calls should now be 1B3. In a project, new EmailNotifier() appears in twelve classes. List two problems and one way to reduce them.
أظهر الحل B3
B3. Problems: switching to another channel means twelve edits, and missing one leaves the program inconsistent; and each class is hard to test because it builds its own real notifier. Reduce them by creating the notifier once (for example in main) and passing it into the classes that need it, typed as Notifier.
Level C: Challenge
C1. A colleague says: "I always declare ArrayList<String> x, because then I can call every method of ArrayList." Give a counter-argument and say when the colleague might be right.
أظهر الحل C1
C1. Counter-argument: if later you want LinkedList-style behavior or a read-only list, you must change every declaration, and the code depends on methods that are not part of the general contract. The colleague may be right when the code genuinely needs an ArrayList-only method and that need is intentional and local. Even then, prefer to keep it in a narrow scope. (Both list types are previewed from the collections module.)
C2. A Report class takes a Notifier but in one method does ((EmailNotifier) notifier).setSubject("..."). Explain why this is a problem and give two ways to fix it.
أظهر الحل C2
C2. Report secretly depends on EmailNotifier: with an SMS notifier the cast fails at run time with a ClassCastException, and tests with a fake break. Fix 1: add the needed operation to the Notifier interface (or add a second small interface that Report takes). Fix 2: pass the subject through the send method's arguments so no email-only call is needed.
Mini Challenge
Take a small class of your own that currently builds its own helper with new: for example a Receipt printer that creates a ConsolePrinter. Refactor it in four steps:
- Extract an interface with the one method the class really calls.
- Make the class hold only the interface type.
- Pass the helper in through the constructor.
- Write a fake that records the call and use it in
mainto verify the output.
Check: the class contains no implementation name, and the new ConsolePrinter() exists only in main.
Summary & Next Lesson
Key takeaways
- Program to an interface: declare fields, parameters, and return types with the interface type.
- Pass a dependency into a class through its constructor. Do not build it inside.
- Choose the concrete class in one place, usually
mainor a setup class. - A fake implementation makes classes easy to test.
- The standard library does the same:
List<String> x = new ArrayList<>();. - Use interfaces at boundaries; not every class needs one.
Next lesson
The next lesson, "Demo: pluggable notification channels", is a follow-along demo. You will build the Notifier setup from scratch, make the planted mistake of depending on the wrong type, and fix it.
Self-check
- Why declare
Notifierinstead ofEmailNotifierinBank? - Where should
new EmailNotifier()normally appear? - How does a fake notifier help in tests?
- What is wrong with casting an interface variable back to its class?
- Name a boundary where an interface is especially useful.
أظهر الإجابات
- So
Bankworks with any notifier and does not change when a channel is added or replaced. - In one place, such as
mainor a setup class. - It records calls instead of really sending, so tests are fast and repeatable.
- It brings back the dependence on the class and fails at run time with other implementations.
- Any outside connection: messaging, storage, another service, the clock.
References
- Oracle, The Java Tutorials: Defining an Interface. Supports the idea that clients can write code against an interface without knowing its implementation and that implementations can change freely.
- Oracle, The Java Tutorials: Abstract Methods and Classes. Supports the guidance on using interfaces to specify behavior without caring who implements it.
- Outputs and compiler messages come from compiling and running the examples with JDK 21. The
List/ArrayListline is a preview, not tested here. "Dependency injection" and the fake-testing advice are common practice described in this course's own words, not cited from a source.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.