Interfaces & Abstract Classes

Designing contracts that enable modularity, testability, and safe API evolution

The SOLID Connection

Before diving into syntax, understand WHY interfaces matter. They're the backbone of SOLID principles:

D - Dependency Inversion Principle

"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.

Interfaces: Pure Contracts

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"
}

The Power of Interfaces

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

Multiple Interfaces: Composing Behaviors

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
}

Default Methods: Evolving Interfaces Safely

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;
    }
}

Abstract Classes: Shared Implementation

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;
    }
}

Interface vs Abstract Class: When to Use Each

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

Modern Best Practice

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.

Functional Interfaces and Lambdas

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]

Real-World Pattern: Repository Interface

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());

Why This Matters at Twilio Scale

Sealed Interfaces (Java 17+)

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
    };
}

When to Use Sealed Types

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.