Designing contracts that enable modularity, testability, and safe API evolution
Before diving into syntax, understand WHY interfaces matter. They're the backbone of SOLID principles:
"Depend on abstractions, not concretions."
Your code should depend on interfaces (what something does), not classes (how it does it). This is the key to testable, modular, evolvable systems.
from abc import ABC, abstractmethod from typing import Protocol # Option 1: ABC (runtime enforcement) class MessageSender(ABC): @abstractmethod def send(self, recipient: str, message: str) -> bool: pass # Option 2: Protocol (type-checker only, duck typing) class MessageSender(Protocol): def send(self, recipient: str, message: str) -> bool: ... # Implementation class EmailSender(MessageSender): def send(self, recipient: str, message: str) -> bool: # Send email return True # Problems: # - ABC only catches at runtime, not IDE/compile time # - Protocol only helps with type checkers, not enforced # - Nothing stops you from forgetting a method until you call it
/** * The CONTRACT: Any MessageSender must implement these methods. * This is enforced by the compiler - not optional, not runtime. */ public interface MessageSender { /** * Send a message to a recipient. * @param recipient The target address * @param message The message content * @return true if sent successfully */ boolean send(String recipient, String message); /** * Check if the sender is currently available. */ boolean isAvailable(); } // Implementation MUST fulfill the contract public class EmailSender implements MessageSender { @Override public boolean send(String recipient, String message) { // Email-specific implementation return true; } @Override public boolean isAvailable() { return true; } // If you forget isAvailable(): COMPILE ERROR // "EmailSender is not abstract and does not override // abstract method isAvailable() in MessageSender" }
If code depends on MessageSender (the interface), you can swap implementations without changing any consumer code:
MessageSender sender = new EmailSender(); // Production MessageSender sender = new SmsSender(); // Alternative MessageSender sender = new MockSender(); // Testing MessageSender sender = new LoggingSender(realSender); // Decorator
Java allows implementing multiple interfaces (unlike single inheritance for classes):
// Separate, focused interfaces (Interface Segregation Principle) public interface Readable { byte[] read(); } public interface Writable { void write(byte[] data); } public interface Closeable { void close(); } // A file implements all three public class File implements Readable, Writable, Closeable { @Override public byte[] read() { /* ... */ } @Override public void write(byte[] data) { /* ... */ } @Override public void close() { /* ... */ } } // A read-only stream only implements Readable public class InputStream implements Readable, Closeable { @Override public byte[] read() { /* ... */ } @Override public void close() { /* ... */ } } // Code that only needs to read: public void processData(Readable source) { byte[] data = source.read(); // Works with File, InputStream, or any Readable }
Java 8 added default methods to solve a real problem: how do you add methods to an interface without breaking all implementations?
public interface MessageSender { boolean send(String recipient, String message); // Added later - doesn't break existing implementations! default boolean sendBulk(List<String> recipients, String message) { boolean allSuccess = true; for (String recipient : recipients) { if (!send(recipient, message)) { allSuccess = false; } } return allSuccess; } // Static utility methods on interfaces static MessageSender noOp() { return (recipient, message) -> true; // Lambda! } } // Existing EmailSender still works - gets default sendBulk() for free // Can override if needed for better performance: public class EmailSender implements MessageSender { @Override public boolean send(String recipient, String message) { // Send single email return true; } @Override public boolean sendBulk(List<String> recipients, String message) { // Optimized bulk send (single SMTP session) return true; } }
Use abstract classes when you have shared code, not just shared contracts:
/** * Abstract class provides: * - Partial implementation (shared code) * - Abstract methods (must be implemented) * - Can have state (fields) * - Can have constructors */ public abstract class AbstractMessageSender implements MessageSender { // Shared state protected final Logger logger = LoggerFactory.getLogger(getClass()); protected final String senderName; // Constructor - subclasses must call this protected AbstractMessageSender(String senderName) { this.senderName = senderName; } // Template method pattern: defines the algorithm structure @Override public final boolean send(String recipient, String message) { logger.info("Sending via {}: {} -> {}", senderName, recipient, message); if (!validate(recipient, message)) { logger.warn("Validation failed"); return false; } try { boolean result = doSend(recipient, message); // Subclass implements logger.info("Send result: {}", result); return result; } catch (Exception e) { logger.error("Send failed", e); return false; } } // Shared validation - can be overridden protected boolean validate(String recipient, String message) { return recipient != null && !recipient.isBlank() && message != null && !message.isBlank(); } // Abstract method - MUST be implemented by subclasses protected abstract boolean doSend(String recipient, String message); } // Concrete implementation only needs to implement doSend() public class EmailSender extends AbstractMessageSender { public EmailSender() { super("Email"); } @Override protected boolean doSend(String recipient, String message) { // Just the email-specific logic return true; } @Override public boolean isAvailable() { return true; } }
| Use Interface When... | Use Abstract Class When... |
|---|---|
| Defining a contract/capability | Providing shared implementation |
| Multiple unrelated classes need the behavior | Classes share an "is-a" relationship |
| You want to allow multiple inheritance of type | You need constructors or state |
| API design for consumers | Code reuse for your own hierarchy |
| Decoupling and testability | Template method pattern |
Prefer interfaces. Use abstract classes only when you have significant shared implementation. You can always add an abstract class later if needed, but changing from abstract class to interface is harder.
An interface with exactly one abstract method can be used with lambdas:
# Python: lambdas are simple inline functions transform = lambda x: x * 2 # Or as a function reference def double(x): return x * 2 numbers = [1, 2, 3] doubled = list(map(double, numbers)) # [2, 4, 6]
// @FunctionalInterface ensures exactly one abstract method @FunctionalInterface public interface Transformer<T> { T transform(T input); } // Can be implemented with a lambda Transformer<Integer> doubler = x -> x * 2; doubler.transform(5); // 10 // Or a method reference Transformer<String> upper = String::toUpperCase; upper.transform("hello"); // "HELLO" // Java provides many built-in functional interfaces: Function<Integer, Integer> doubler2 = x -> x * 2; Predicate<String> isEmpty = String::isEmpty; Consumer<String> printer = System.out::println; Supplier<LocalDate> today = LocalDate::now; // Used with streams List<Integer> numbers = List.of(1, 2, 3); List<Integer> doubled = numbers.stream() .map(x -> x * 2) .toList(); // [2, 4, 6]
Here's how interfaces enable clean architecture in enterprise applications:
// The CONTRACT - defined in your domain layer public interface UserRepository { Optional<User> findById(String id); Optional<User> findByEmail(String email); List<User> findAll(); User save(User user); void delete(String id); } // Production implementation - PostgreSQL public class PostgresUserRepository implements UserRepository { private final JdbcTemplate jdbc; @Override public Optional<User> findById(String id) { // Real database query return jdbc.queryForObject("SELECT * FROM users WHERE id = ?", ...); } // ... other methods } // Test implementation - in-memory public class InMemoryUserRepository implements UserRepository { private final Map<String, User> users = new HashMap<>(); @Override public Optional<User> findById(String id) { return Optional.ofNullable(users.get(id)); } // ... other methods - fast, no database needed } // Your service doesn't know or care which implementation it uses public class UserService { private final UserRepository repository; // Interface, not class! public UserService(UserRepository repository) { this.repository = repository; // Injected - could be anything } public User getUser(String id) { return repository.findById(id) .orElseThrow(() -> new UserNotFoundException(id)); } } // In production (Spring, Guice, etc.): new UserService(new PostgresUserRepository(jdbc)); // In tests: new UserService(new InMemoryUserRepository());
Control exactly which classes can implement your interface:
// Only these three can implement Result public sealed interface Result<T> permits Success, Failure, Pending { } public record Success<T>(T value) implements Result<T> { } public record Failure<T>(String error) implements Result<T> { } public record Pending<T>() implements Result<T> { } // Pattern matching knows all cases (exhaustive) public String describe(Result<String> result) { return switch (result) { case Success<String> s -> "Got: " + s.value(); case Failure<String> f -> "Error: " + f.error(); case Pending<String> p -> "Waiting..."; // No default needed - compiler knows these are ALL cases }; }
Use sealed types when you have a closed set of implementations and want the compiler to enforce exhaustive handling. Common for algebraic data types, state machines, and result types.