Loading content...
In the previous page, we established when abstract classes are the right choice. Now we examine the complementary question: When should you choose an interface?
Interfaces offer something abstract classes cannot: complete separation between contract and implementation. This separation is foundational to many modern architectural patterns, testing strategies, and extensibility mechanisms.
Understanding when interfaces are superior to abstract classes is crucial for building systems that are flexible, testable, and resilient to change.
By the end of this page, you will have a decision framework for choosing interfaces. You'll understand the specific scenarios where interfaces provide irreplaceable value, from multiple implementation needs to testability to architectural boundaries.
Choose an interface when:
Multiple implementations exist or are expected — Different providers, strategies, or backends
You need testability via substitution — Mock/stub/fake implementations for testing
You're defining a capability, not an identity — CAN-DO rather than IS-A
Multiple inheritance is needed — A type must fulfill multiple unrelated contracts
You want loose coupling — Consumers shouldn't know about implementation details
You're defining API boundaries — Between modules, layers, or services
If you need substantial shared implementation, protected member access, or template algorithms—reconsider abstract classes. Otherwise, interfaces are often the more flexible choice.
| Question | Yes → Interface | No → Abstract Class |
|---|---|---|
| Are there multiple implementations? | Define capability via interface | Single implementation may not need abstraction |
| Do you need to mock for testing? | Interface enables substitution | Concrete class may suffice for unit |
| Is it a capability, not identity? | CAN-DO → Interface | IS-A → Abstract class |
| Does type need multiple contracts? | Interfaces compose | Single inheritance limits abstract classes |
| Different teams build implementations? | Interface is the contract | Shared code needs coordination |
When in doubt, start with an interface. You can always introduce an abstract class later if shared implementation needs arise. Going the other direction—extracting an interface from an abstract class—is harder because consumers may already depend on concrete implementation details.
The most common reason to use an interface is multiple implementations. When you have—or anticipate—different ways to provide the same capability, an interface is the natural abstraction.
Real-World Examples:
In each case, the consumer doesn't care which implementation is used—they care only that the capability exists.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
// ═══════════════════════════════════════════════════// INTERFACE: Defines what ALL storage providers must do// ═══════════════════════════════════════════════════ public interface ObjectStorage { /** * Upload an object to storage. * @return URL or identifier for the stored object */ String upload(String key, byte[] content, ContentType contentType); /** * Download an object from storage. * @return The object content, or empty if not found */ Optional<byte[]> download(String key); /** * Delete an object from storage. * @return true if deletion succeeded */ boolean delete(String key); /** * Check if an object exists. */ boolean exists(String key); /** * Get a pre-signed URL for direct access (e.g., for browser downloads). * @param expirationMinutes How long the URL should be valid */ String getSignedUrl(String key, int expirationMinutes); /** * List objects with a given prefix. */ List<StoredObject> list(String prefix, int limit);} // ═══════════════════════════════════════════════════// IMPLEMENTATION 1: Amazon S3// ═══════════════════════════════════════════════════ public class S3ObjectStorage implements ObjectStorage { private final S3Client s3; private final String bucket; public S3ObjectStorage(S3Client s3, String bucket) { this.s3 = s3; this.bucket = bucket; } @Override public String upload(String key, byte[] content, ContentType contentType) { s3.putObject( PutObjectRequest.builder() .bucket(bucket) .key(key) .contentType(contentType.getMimeType()) .build(), RequestBody.fromBytes(content) ); return String.format("s3://%s/%s", bucket, key); } @Override public Optional<byte[]> download(String key) { try { ResponseBytes<GetObjectResponse> response = s3.getObjectAsBytes( GetObjectRequest.builder() .bucket(bucket) .key(key) .build() ); return Optional.of(response.asByteArray()); } catch (NoSuchKeyException e) { return Optional.empty(); } } @Override public String getSignedUrl(String key, int expirationMinutes) { S3Presigner presigner = S3Presigner.create(); GetObjectPresignRequest request = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(expirationMinutes)) .getObjectRequest(r -> r.bucket(bucket).key(key)) .build(); return presigner.presignGetObject(request).url().toString(); } // ... other implementations} // ═══════════════════════════════════════════════════// IMPLEMENTATION 2: Google Cloud Storage// ═══════════════════════════════════════════════════ public class GcsObjectStorage implements ObjectStorage { private final Storage storage; private final String bucket; public GcsObjectStorage(Storage storage, String bucket) { this.storage = storage; this.bucket = bucket; } @Override public String upload(String key, byte[] content, ContentType contentType) { BlobId blobId = BlobId.of(bucket, key); BlobInfo blobInfo = BlobInfo.newBuilder(blobId) .setContentType(contentType.getMimeType()) .build(); storage.create(blobInfo, content); return String.format("gs://%s/%s", bucket, key); } @Override public Optional<byte[]> download(String key) { Blob blob = storage.get(BlobId.of(bucket, key)); if (blob == null) { return Optional.empty(); } return Optional.of(blob.getContent()); } @Override public String getSignedUrl(String key, int expirationMinutes) { BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucket, key)).build(); URL url = storage.signUrl( blobInfo, expirationMinutes, TimeUnit.MINUTES, Storage.SignUrlOption.withV4Signature() ); return url.toString(); } // ... other implementations} // ═══════════════════════════════════════════════════// IMPLEMENTATION 3: Local Filesystem (for development)// ═══════════════════════════════════════════════════ public class LocalFileStorage implements ObjectStorage { private final Path basePath; private final String baseUrl; public LocalFileStorage(Path basePath, String baseUrl) { this.basePath = basePath; this.baseUrl = baseUrl; } @Override public String upload(String key, byte[] content, ContentType contentType) { try { Path filePath = basePath.resolve(key); Files.createDirectories(filePath.getParent()); Files.write(filePath, content); return baseUrl + "/" + key; } catch (IOException e) { throw new StorageException("Failed to write file", e); } } @Override public Optional<byte[]> download(String key) { try { Path filePath = basePath.resolve(key); if (!Files.exists(filePath)) { return Optional.empty(); } return Optional.of(Files.readAllBytes(filePath)); } catch (IOException e) { throw new StorageException("Failed to read file", e); } } @Override public String getSignedUrl(String key, int expirationMinutes) { // Local storage doesn't support signed URLs // Just return the direct URL return baseUrl + "/" + key; } // ... other implementations} // ═══════════════════════════════════════════════════// CONSUMER: Uses interface, agnostic to implementation// ═══════════════════════════════════════════════════ public class FileUploadService { private final ObjectStorage storage; // Interface dependency // Implementation injected - service doesn't know or care which one public FileUploadService(ObjectStorage storage) { this.storage = storage; } public UploadResult uploadUserAvatar(String userId, MultipartFile file) { String key = "avatars/" + userId + "/" + generateUniqueId() + getExtension(file.getOriginalFilename()); String url = storage.upload(key, file.getBytes(), file.getContentType()); return new UploadResult(key, url); } public byte[] downloadFile(String key) { return storage.download(key) .orElseThrow(() -> new FileNotFoundException("File not found: " + key)); }}With interface-based design, implementation selection can happen at runtime via configuration. Development uses LocalFileStorage, staging uses GcsObjectStorage, production uses S3ObjectStorage—all without changing the FileUploadService code.
Interfaces are essential for unit testing. When your code depends on an interface, you can substitute a test double (mock, stub, fake) for the real implementation—enabling isolated, fast, and deterministic tests.
Why This Matters:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
// ═══════════════════════════════════════════════════// INTERFACE: Contract for external API calls// ═══════════════════════════════════════════════════ public interface WeatherService { WeatherData getCurrentWeather(String city); List<WeatherData> getForecast(String city, int days); boolean isApiHealthy();} // ═══════════════════════════════════════════════════// PRODUCTION IMPLEMENTATION: Real API calls// ═══════════════════════════════════════════════════ public class OpenWeatherMapService implements WeatherService { private final HttpClient httpClient; private final String apiKey; @Override public WeatherData getCurrentWeather(String city) { // Real HTTP call to OpenWeatherMap API String url = String.format( "https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s", city, apiKey ); HttpResponse response = httpClient.get(url); return parseWeatherResponse(response.getBody()); } // Real network latency, real API quotas, real costs} // ═══════════════════════════════════════════════════// SERVICE UNDER TEST: Uses WeatherService interface// ═══════════════════════════════════════════════════ public class TravelRecommendationService { private final WeatherService weatherService; // Interface dependency private final DestinationRepository destinations; public TravelRecommendationService( WeatherService weatherService, DestinationRepository destinations ) { this.weatherService = weatherService; this.destinations = destinations; } public List<Recommendation> getRecommendations(TravelPreferences prefs) { List<Recommendation> recommendations = new ArrayList<>(); for (Destination destination : destinations.findAll()) { WeatherData weather = weatherService.getCurrentWeather( destination.getCity() ); if (matchesPreferences(weather, prefs)) { double score = calculateScore(destination, weather, prefs); recommendations.add(new Recommendation(destination, score)); } } recommendations.sort(Comparator.comparing(Recommendation::getScore).reversed()); return recommendations.subList(0, Math.min(10, recommendations.size())); } public ServiceStatus getStatus() { boolean weatherHealthy = weatherService.isApiHealthy(); return new ServiceStatus(weatherHealthy); } private boolean matchesPreferences(WeatherData weather, TravelPreferences prefs) { return weather.getTemperature() >= prefs.getMinTemp() && weather.getTemperature() <= prefs.getMaxTemp() && (!prefs.isAvoidRain() || !weather.isRaining()); }} // ═══════════════════════════════════════════════════// TEST DOUBLE: Fake implementation for testing// ═══════════════════════════════════════════════════ public class FakeWeatherService implements WeatherService { private final Map<String, WeatherData> weatherByCity = new HashMap<>(); private boolean healthy = true; private int callCount = 0; // Test setup: Configure what weather data to return public void setWeatherFor(String city, WeatherData data) { weatherByCity.put(city.toLowerCase(), data); } public void setHealthy(boolean healthy) { this.healthy = healthy; } @Override public WeatherData getCurrentWeather(String city) { callCount++; WeatherData data = weatherByCity.get(city.toLowerCase()); if (data == null) { throw new CityNotFoundException("Unknown city: " + city); } return data; } @Override public boolean isApiHealthy() { return healthy; } // Test verification: Check interactions public int getCallCount() { return callCount; } public void reset() { weatherByCity.clear(); callCount = 0; healthy = true; }} // ═══════════════════════════════════════════════════// UNIT TESTS: Fast, isolated, deterministic// ═══════════════════════════════════════════════════ class TravelRecommendationServiceTest { private FakeWeatherService fakeWeatherService; private InMemoryDestinationRepository fakeRepository; private TravelRecommendationService service; @BeforeEach void setUp() { fakeWeatherService = new FakeWeatherService(); fakeRepository = new InMemoryDestinationRepository(); service = new TravelRecommendationService(fakeWeatherService, fakeRepository); } @Test void recommendsDestinationsMatchingTemperaturePreference() { // Arrange: Set up test data fakeRepository.save(new Destination("Paris", "France")); fakeRepository.save(new Destination("Miami", "USA")); fakeRepository.save(new Destination("Reykjavik", "Iceland")); fakeWeatherService.setWeatherFor("Paris", new WeatherData(20, false)); // 20°C, not raining fakeWeatherService.setWeatherFor("Miami", new WeatherData(32, false)); // 32°C, not raining fakeWeatherService.setWeatherFor("Reykjavik", new WeatherData(5, true)); // 5°C, raining TravelPreferences prefs = TravelPreferences.builder() .minTemp(15) .maxTemp(25) .avoidRain(true) .build(); // Act List<Recommendation> results = service.getRecommendations(prefs); // Assert assertEquals(1, results.size()); assertEquals("Paris", results.get(0).getDestination().getCity()); } @Test void handlesWeatherApiUnavailable() { // Arrange: Simulate API failure fakeWeatherService.setHealthy(false); // Act ServiceStatus status = service.getStatus(); // Assert assertFalse(status.isWeatherApiHealthy()); } @Test void handlesUnknownCity() { // Arrange: City not in fake data fakeRepository.save(new Destination("Atlantis", "Ocean")); // Note: No weather data set for Atlantis TravelPreferences prefs = TravelPreferences.defaultPrefs(); // Act & Assert: Should handle gracefully assertThrows( CityNotFoundException.class, () -> service.getRecommendations(prefs) ); }}If you're writing 'new RealDependency()' inside a class, that class is hard to test. Extract an interface for the dependency and inject it via constructor. Now tests can inject fakes, and production code can inject real implementations. This simple pattern—Interface + Dependency Injection—is the foundation of testable design.
Unlike classes (which typically allow only single inheritance), interfaces support multiple inheritance. A class can implement any number of interfaces, acquiring multiple capabilities simultaneously.
This enables capability composition—building complex types by combining simple, focused contracts without inheritance hierarchy complexity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
// ═══════════════════════════════════════════════════// FOCUSED INTERFACES: Each defines ONE capability// ═══════════════════════════════════════════════════ // Can persist to databasepublic interface Persistable { void save(); void delete(); boolean isPersisted();} // Can be serialized to JSONpublic interface JsonSerializable { String toJson(); void fromJson(String json);} // Can be compared for equality and orderingpublic interface Comparable<T> { int compareTo(T other);} // Can be validated before usepublic interface Validatable { List<ValidationError> validate(); default boolean isValid() { return validate().isEmpty(); }} // Can be cloned/copiedpublic interface Cloneable<T> { T deepClone();} // Can be cached with TTL semanticspublic interface Cacheable { String getCacheKey(); Duration getTimeToLive(); default boolean shouldCache() { return true; }} // Has audit trailpublic interface Auditable { String getCreatedBy(); Instant getCreatedAt(); String getLastModifiedBy(); Instant getLastModifiedAt();} // ═══════════════════════════════════════════════════// DOMAIN OBJECT: Implements MULTIPLE interfaces// ═══════════════════════════════════════════════════ public class Product implements Persistable, JsonSerializable, Comparable<Product>, Validatable, Cloneable<Product>, Cacheable, Auditable { private Long id; private String sku; private String name; private Money price; private int stockQuantity; private ProductStatus status; // Audit fields private String createdBy; private Instant createdAt; private String lastModifiedBy; private Instant lastModifiedAt; // ───────────────────────────────────────────────── // Persistable implementation // ───────────────────────────────────────────────── @Override public void save() { // Delegate to repository (would be injected in real code) ProductRepository.getInstance().save(this); } @Override public void delete() { ProductRepository.getInstance().delete(this.id); } @Override public boolean isPersisted() { return this.id != null; } // ───────────────────────────────────────────────── // JsonSerializable implementation // ───────────────────────────────────────────────── @Override public String toJson() { return new ObjectMapper().writeValueAsString(this); } @Override public void fromJson(String json) { Product parsed = new ObjectMapper().readValue(json, Product.class); this.sku = parsed.sku; this.name = parsed.name; // ... copy other fields } // ───────────────────────────────────────────────── // Comparable implementation // ───────────────────────────────────────────────── @Override public int compareTo(Product other) { // Sort by SKU for consistent ordering return this.sku.compareTo(other.sku); } // ───────────────────────────────────────────────── // Validatable implementation // ───────────────────────────────────────────────── @Override public List<ValidationError> validate() { List<ValidationError> errors = new ArrayList<>(); if (sku == null || sku.isBlank()) { errors.add(new ValidationError("sku", "SKU is required")); } if (name == null || name.length() < 3) { errors.add(new ValidationError("name", "Name must be at least 3 characters")); } if (price == null || price.isNegativeOrZero()) { errors.add(new ValidationError("price", "Price must be positive")); } if (stockQuantity < 0) { errors.add(new ValidationError("stockQuantity", "Stock cannot be negative")); } return errors; } // ───────────────────────────────────────────────── // Cloneable implementation // ───────────────────────────────────────────────── @Override public Product deepClone() { Product clone = new Product(); clone.sku = this.sku + "-COPY"; clone.name = this.name; clone.price = this.price; // Money is immutable clone.stockQuantity = this.stockQuantity; clone.status = this.status; // Note: id is null (not persisted), audit fields reset return clone; } // ───────────────────────────────────────────────── // Cacheable implementation // ───────────────────────────────────────────────── @Override public String getCacheKey() { return "product:" + this.sku; } @Override public Duration getTimeToLive() { // Hot products cached longer return status == ProductStatus.FEATURED ? Duration.ofHours(1) : Duration.ofMinutes(15); } // ───────────────────────────────────────────────── // Auditable implementation // ───────────────────────────────────────────────── @Override public String getCreatedBy() { return createdBy; } @Override public Instant getCreatedAt() { return createdAt; } @Override public String getLastModifiedBy() { return lastModifiedBy; } @Override public Instant getLastModifiedAt() { return lastModifiedAt; }} // ═══════════════════════════════════════════════════// SERVICES: Depend on specific capabilities// ═══════════════════════════════════════════════════ // Cache service cares about Cacheablepublic class CacheService { private final Cache cache; public <T extends Cacheable> void cache(T item) { cache.put(item.getCacheKey(), item, item.getTimeToLive()); }} // Audit service cares about Auditablepublic class AuditService { public void logChange(Auditable entity, String action) { log.info("Action {} on entity at {} by {}", action, entity.getLastModifiedAt(), entity.getLastModifiedBy() ); }} // Validation service cares about Validatablepublic class ValidationService { public void ensureValid(Validatable entity) { List<ValidationError> errors = entity.validate(); if (!errors.isEmpty()) { throw new ValidationException(errors); } }} // Export service cares about JsonSerializablepublic class ExportService { public void exportToFile(List<? extends JsonSerializable> items, Path path) { List<String> jsonLines = items.stream() .map(JsonSerializable::toJson) .toList(); Files.write(path, jsonLines); }}Notice how each service depends only on the interface it needs. CacheService doesn't know Product is Auditable; AuditService doesn't know Product is Cacheable. This is Interface Segregation Principle: clients depend only on methods they use, achieved through multiple focused interfaces.
Interfaces are the primary tool for defining architectural boundaries—the contracts between layers, modules, and services. They specify what one part of the system provides to another without coupling either to implementation details.
Common Boundary Patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
// ═══════════════════════════════════════════════════// HEXAGONAL ARCHITECTURE: Ports define boundaries// ═══════════════════════════════════════════════════ // ── DOMAIN LAYER: Pure business logic ────────────── // The domain defines what it NEEDS (driven ports/interfaces)package com.app.domain.ports.outbound; // Domain needs to persist orderspublic interface OrderRepository { void save(Order order); Optional<Order> findById(OrderId id); List<Order> findByCustomer(CustomerId customerId);} // Domain needs to send notificationspublic interface NotificationPort { void sendOrderConfirmation(Order order); void sendShippingUpdate(Order order, ShippingStatus status);} // Domain needs to process paymentspublic interface PaymentPort { PaymentResult processPayment(Order order, PaymentDetails payment); RefundResult refundPayment(String transactionId, Money amount);} // Domain needs to check inventorypublic interface InventoryPort { boolean checkAvailability(ProductId productId, int quantity); void reserveStock(ProductId productId, int quantity); void releaseStock(ProductId productId, int quantity);} // Domain also defines what it PROVIDES (driving ports)package com.app.domain.ports.inbound; // OrderService is the entry point for order operationspublic interface OrderService { OrderId createOrder(CreateOrderCommand command); void addItem(OrderId orderId, AddItemCommand command); void submit(OrderId orderId); void cancel(OrderId orderId); OrderDto getOrder(OrderId orderId);} // Domain implementation (core business logic)package com.app.domain.services; public class OrderServiceImpl implements OrderService { // Depends on INTERFACES (ports), not implementations private final OrderRepository orderRepository; private final InventoryPort inventoryPort; private final PaymentPort paymentPort; private final NotificationPort notificationPort; public OrderServiceImpl( OrderRepository orderRepository, InventoryPort inventoryPort, PaymentPort paymentPort, NotificationPort notificationPort ) { this.orderRepository = orderRepository; this.inventoryPort = inventoryPort; this.paymentPort = paymentPort; this.notificationPort = notificationPort; } @Override public void submit(OrderId orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); // Business logic - pure domain order.validate(); // Reserve inventory for (OrderItem item : order.getItems()) { inventoryPort.reserveStock(item.getProductId(), item.getQuantity()); } // Process payment PaymentResult payment = paymentPort.processPayment( order, order.getPaymentDetails() ); if (!payment.isSuccessful()) { // Release reserved stock for (OrderItem item : order.getItems()) { inventoryPort.releaseStock(item.getProductId(), item.getQuantity()); } throw new PaymentFailedException(payment.getError()); } order.markSubmitted(payment.getTransactionId()); orderRepository.save(order); // Send confirmation notificationPort.sendOrderConfirmation(order); }} // ── INFRASTRUCTURE LAYER: Implements ports ───────── package com.app.infrastructure.persistence; // JPA implementation of OrderRepository portpublic class JpaOrderRepository implements OrderRepository { private final EntityManager em; private final OrderMapper mapper; @Override public void save(Order order) { OrderEntity entity = mapper.toEntity(order); em.persist(entity); } @Override public Optional<Order> findById(OrderId id) { OrderEntity entity = em.find(OrderEntity.class, id.getValue()); return Optional.ofNullable(entity).map(mapper::toDomain); }} package com.app.infrastructure.payment; // Stripe implementation of PaymentPortpublic class StripePaymentAdapter implements PaymentPort { private final StripeClient stripe; @Override public PaymentResult processPayment(Order order, PaymentDetails details) { // Stripe-specific API calls PaymentIntent intent = stripe.paymentIntents().create( PaymentIntentCreateParams.builder() .setAmount(order.getTotal().toCents()) .setCurrency(order.getCurrency()) .setPaymentMethod(details.getStripePaymentMethodId()) .build() ); return new PaymentResult( intent.getStatus().equals("succeeded"), intent.getId(), intent.getClientSecret() ); }} package com.app.infrastructure.notification; // SendGrid implementation of NotificationPortpublic class SendGridNotificationAdapter implements NotificationPort { private final SendGrid sendGrid; private final EmailTemplates templates; @Override public void sendOrderConfirmation(Order order) { Email email = templates.orderConfirmation(order); sendGrid.send(email); }} // ── CONFIGURATION: Wires ports to adapters ───────── package com.app.config; @Configurationpublic class DomainConfig { @Bean public OrderService orderService( OrderRepository orderRepository, InventoryPort inventoryPort, PaymentPort paymentPort, NotificationPort notificationPort ) { return new OrderServiceImpl( orderRepository, inventoryPort, paymentPort, notificationPort ); }} @Configurationpublic class InfrastructureConfig { @Bean public OrderRepository orderRepository(EntityManager em) { return new JpaOrderRepository(em); } @Bean @Profile("production") public PaymentPort paymentPort(StripeClient stripe) { return new StripePaymentAdapter(stripe); } @Bean @Profile("test") public PaymentPort testPaymentPort() { return new MockPaymentAdapter(); // For testing }}In Hexagonal Architecture, interfaces are 'ports' (what the domain needs/provides) and implementations are 'adapters' (how infrastructure fulfills those needs). The domain is the center—it defines ports. Infrastructure adapts to those ports. This keeps domain logic pure and infrastructure concerns swappable.
Interfaces are ideal for CAN-DO relationships—when you're defining what a type can do rather than what it is.
The Distinction:
Many types from different hierarchies can share a capability. A Robot, a Dog, and a Human can all implement Walkable—but they have no shared identity. They just share what they can do.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// ═══════════════════════════════════════════════════// CAPABILITY INTERFACES: Diverse types share behaviors// ═══════════════════════════════════════════════════ // Anything that can be rendered to a displaypublic interface Renderable { void render(Canvas canvas); Rectangle getBoundingBox();} // Implementation: Game entitiespublic class Player implements Renderable { @Override public void render(Canvas canvas) { canvas.drawSprite(playerSprite, x, y); }} // Implementation: UI elementspublic class HealthBar implements Renderable { @Override public void render(Canvas canvas) { canvas.drawRect(x, y, width * healthPercent, height, Color.GREEN); }} // Implementation: Effectspublic class Explosion implements Renderable { @Override public void render(Canvas canvas) { canvas.drawAnimation(explosionFrames, x, y, currentFrame); }} // Rendering system doesn't care WHAT things are// Only that they CAN BE renderedpublic class RenderingEngine { private final List<Renderable> renderables; public void renderFrame(Canvas canvas) { // Sort by z-order for proper layering renderables.stream() .sorted(Comparator.comparing(r -> r.getBoundingBox().getZ())) .forEach(r -> r.render(canvas)); }} // ═══════════════════════════════════════════════════// MORE EXAMPLES: Diverse types, shared capability// ═══════════════════════════════════════════════════ // Anything that can be scheduledpublic interface Schedulable { void execute(); Instant getScheduledTime(); Duration getRetryDelay(); int getMaxRetries();} // Implementation: Email campaignspublic class EmailCampaign implements Schedulable { ... } // Implementation: Report generationpublic class ReportJob implements Schedulable { ... } // Implementation: Data cleanuppublic class DataPurgeTask implements Schedulable { ... } // Implementation: External API syncpublic class ApiSyncJob implements Schedulable { ... } // Scheduler doesn't care what it's scheduling// Only that items CAN BE scheduledpublic class JobScheduler { public void schedule(Schedulable job) { queue.addAt(job.getScheduledTime(), job); } public void processQueue() { while (true) { Schedulable next = queue.pollNext(); try { next.execute(); } catch (Exception e) { if (next.getMaxRetries() > 0) { reschedule(next); } } } }}When modeling, ask: Does 'X CAN Y' make sense, or does 'X IS A Y' make sense? If a Player 'IS A Renderable' sounds odd but 'CAN BE rendered' sounds right—use an interface. If a Dog 'IS A Pet' makes sense—consider an abstract class or inheritance.
We've established comprehensive criteria for choosing interfaces. Let's consolidate the decision framework:
What's Next:
With clear understanding of when to use abstract classes and when to use interfaces, the final page explores modern interface features—particularly default methods—that blur traditional boundaries between these two abstractions.
You now have a complete framework for choosing interfaces: multiple implementations, testability, capability composition, architectural boundaries, and CAN-DO relationships. Next, we'll explore how modern languages have evolved interfaces with features like default methods.