1. Interfaces as a contract: the foundation of architecture
In Java (and beyond), an interface is not just a set of methods. It is a contract: a promise that any class implementing the interface supports a specific behavior. The interface defines what must be implemented, not how.
Why is this important?
- Layering the code. Thanks to interfaces, we can separate “what it does” from “how it does it.” For example, if you have a PaymentService interface, different implementations can process payments by bank card, PayPal, or cryptocurrency, but the code that calls pay() does not care about the details.
- Flexibility and extensibility. You can add a new interface implementation without changing the rest of the code. This is especially important in large teams and long-lived projects.
- Testability. Interfaces make it easy to substitute implementations with test doubles (mocks) without touching production code.
Example: service layer and DAO
Consider a classic example from business applications. Suppose we have an interface for working with users:
public interface UserRepository {
User findById(int id);
void save(User user);
}
In different scenarios we can implement this interface in different ways:
- DatabaseUserRepository — stores users in a database.
- InMemoryUserRepository — stores users in memory (convenient for tests).
- FileUserRepository — saves users to a file.
The code that works with users depends only on the interface:
public class UserService {
private final UserRepository userRepository;
// Dependency injection via constructor
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
userRepository.save(user);
}
}
Now we can easily swap the UserRepository implementation without changing the service code.
2. Dependency Injection (DI) and the role of interfaces
Dependency Injection (DI) is an architectural technique in which dependencies (for example, interface implementations) are provided to an object from the outside, usually via a constructor or setter. This enables flexible, easily testable, and extensible applications.
Why are interfaces important for DI?
If we hard-coded a concrete implementation in the code, replacing it would be difficult. With interfaces, we can plug in any implementation without changing the main code.
Example with dependency injection
public interface NotificationSender {
void send(String message);
}
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// A class that uses NotificationSender
public class NotificationService {
private final NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
Now you can easily test NotificationService by passing it, for example, a “stub” instead of a real message sender.
3. Design patterns and interfaces
Interfaces are not only about architecture, but also about design patterns. Many patterns are hard to implement without interfaces. Let’s look at the most popular ones.
Observer
Observer is a pattern that allows one object (the subject) to notify other objects (observers) about changes to its state.
UML diagram (simplified):
+------------------+ +------------------------+
| Subject |<------->| Observer |
+------------------+ +------------------------+
| +addObserver() | | +update() |
| +removeObserver()| +------------------------+
| +notifyObservers()|
+------------------+
Code example:
import java.util.ArrayList;
import java.util.List;
// Observer interface
public interface Observer {
void update(String event);
}
// Subject interface
public interface Subject {
void addObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(String event);
}
// Subject implementation
public class NewsAgency implements Subject {
private List<Observer> observers = new ArrayList<>();
@Override
public void addObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}
// Observer implementation
public class NewsReader implements Observer {
private String name;
public NewsReader(String name) {
this.name = name;
}
@Override
public void update(String event) {
System.out.println(name + " received the news: " + event);
}
}
// Main class to run the example
public class ObserverExample {
public static void main(String[] args) {
// Create a "news agency" (subject)
NewsAgency agency = new NewsAgency();
// Create observers
Observer alice = new NewsReader("Alice");
Observer bob = new NewsReader("Bob");
// Subscribe observers to news
agency.addObserver(alice);
agency.addObserver(bob);
// Send a news item
agency.notifyObservers("A new version of Java has been released!");
// Remove one observer and send another news item
agency.removeObserver(bob);
agency.notifyObservers("Next news for subscribers");
}
}
Result:
Alice received the news: A new version of Java has been released!
Bob received the news: A new version of Java has been released!
Strategy
Strategy is a pattern that lets you choose an algorithm at runtime without changing client code.
UML diagram (simplified):
+------------------+
| Context |
+------------------+
| -strategy: Strat.|
| +setStrategy() |
| +execute() |
+------------------+
|
v
+------------------+
| Strategy |<-------------------------+
+------------------+ |
| +execute() | |
+------------------+ |
^ |
| |
+------------------+ +------------------+ |
| ConcreteA | | ConcreteB |---+
+------------------+ +------------------+
| +execute() | | +execute() |
+------------------+ +------------------+
Code example:
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Payment " + amount + " RUB by credit card");
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("Payment " + amount + " RUB via PayPal");
}
}
public class OnlineStore {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// Usage:
OnlineStore store = new OnlineStore();
store.setPaymentStrategy(new CreditCardPayment());
store.checkout(1000);
store.setPaymentStrategy(new PaypalPayment());
store.checkout(500);
Result:
Payment 1000 RUB by credit card
Payment 500 RUB via PayPal
Command
Command is a pattern that encapsulates a request as an object, allowing you to pass actions as parameters.
Code example:
public interface Command {
void execute();
}
public class LightOnCommand implements Command {
@Override
public void execute() {
System.out.println("Light turned on!");
}
}
public class LightOffCommand implements Command {
@Override
public void execute() {
System.out.println("Light turned off!");
}
}
public class RemoteControl {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void pressButton() {
command.execute();
}
}
// Usage:
RemoteControl remote = new RemoteControl();
remote.setCommand(new LightOnCommand());
remote.pressButton(); // Light turned on!
remote.setCommand(new LightOffCommand());
remote.pressButton(); // Light turned off!
4. Advantages of using interfaces in architecture
- Low coupling. Code depends only on the interface, not on a concrete implementation. This makes replacement, testing, and extension easier.
- Testability. It is easy to replace the real implementation with a test double (mock/stub) when writing unit tests.
- Extensibility. You can add new implementations of an interface without changing existing code — the open/closed principle (OCP).
- Parallel development. Multiple teams can independently implement different parts of the system if they share a common interface.
- Architectural flexibility. It is easy to introduce new patterns and approaches.
5. Common mistakes when using interfaces in architecture
Mistake #1: Tight coupling to a concrete implementation.
If you use concrete classes everywhere instead of interfaces, any change to the implementation will require rewriting code in many places. Always try to program “at the interface level.”
Mistake #2: Interfaces that are too large (God Interface).
An interface should be compact and responsible for a single area. Do not stuff everything into one interface — otherwise the implementation becomes heavy and confusing.
Mistake #3: Ignoring the benefits of testability.
If you do not use interfaces to replace dependencies in tests, your tests can become slow and unreliable, especially if they interact with real databases or networks.
Mistake #4: Multiple implementations but no DI.
If you created several implementations of an interface but hard-coded one of them in the code, you lose all architectural flexibility. Use dependency injection (DI)!
GO TO FULL VERSION