Loading learning content...
We've explored why to depend on abstractions, how interfaces enable flexibility, and how they reduce coupling. Now we bring everything together into a cohesive approach: interface-based design.
In interface-based design, interfaces aren't an afterthought or a testing convenience—they're the primary architectural element. You design interfaces first, before implementations. You think in terms of contracts and collaborations, not classes and methods. The structure of your system emerges from the way interfaces interact.
This approach transforms how you design software. Instead of asking "What classes do I need?", you ask "What contracts define how components collaborate?" The resulting systems are more flexible, more testable, and more aligned with the problem domain.
By the end of this page, you will understand how to apply interface-first design, structure systems around collaborating interfaces, and use interfaces to model domain concepts. You'll have a complete framework for designing systems where interfaces drive architecture.
Interface-first design inverts the typical development flow. Instead of writing classes and extracting interfaces later, you define interfaces first based on the capabilities your system needs.
The interface-first process:
Example: Designing an e-commerce checkout system
Step 1: Identify capabilities
12345678910
Required capabilities for checkout:- Calculate pricing (discounts, taxes, shipping)- Validate inventory availability- Process payment- Create order record- Reserve inventory- Send confirmation notification- Handle failures gracefully Each capability becomes an interface.Step 2: Define interfaces
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Price calculation capabilitypublic interface PricingService { PricingResult calculatePrice(Cart cart, Customer customer, ShippingOption shipping);} public record PricingResult( Money subtotal, Money discount, Money tax, Money shipping, Money total, List<AppliedPromotion> promotions) {} // Inventory capabilitypublic interface InventoryService { AvailabilityResult checkAvailability(List<CartItem> items); ReservationResult reserve(List<CartItem> items, Duration holdTime); void release(ReservationId reservationId);} // Payment capabilitypublic interface PaymentGateway { PaymentResult authorize(Money amount, PaymentMethod method); PaymentResult capture(PaymentId authorizationId); void cancel(PaymentId authorizationId);} // Order persistence capabilitypublic interface OrderRepository { Order save(Order order); Optional<Order> findById(OrderId id); void updateStatus(OrderId id, OrderStatus status);} // Notification capabilitypublic interface NotificationService { void sendOrderConfirmation(Order order, Customer customer); void sendOrderFailed(Customer customer, String reason);} // Each interface is:// - Named by capability (what it does), not implementation (how)// - Minimal (only necessary methods)// - Complete (everything needed for that capability)// - Documented (contracts are explicit)Step 3: Model collaborations
Notice that we've defined the structure of the checkout system before writing any implementation code. We know what capabilities exist, what each provides, and how they collaborate. Implementation is filling in boxes of a predefined structure.
The quality of your interfaces determines the quality of your system. Well-designed interfaces are cohesive—all methods relate to a single capability—and complete—they provide everything needed for that capability.
Principles for cohesive interfaces:
| Principle | Description | Example |
|---|---|---|
| Single Capability | All methods serve one coherent purpose | OrderRepository only handles order persistence, not pricing |
| Complete Abstraction | Include all operations needed for the capability | PaymentGateway includes authorize, capture, AND cancel |
| Meaningful Names | Names describe what, not how | NotificationService, not EmailAndSmsNotifier |
| Appropriate Granularity | Neither too broad nor too narrow | One InventoryService vs split into AvailabilityChecker, Reserver, etc. |
| Implementation Agnostic | No implementation details leak through | No getDbConnection() on repository interfaces |
| Explicit Contracts | Document behavior, exceptions, thread safety | Javadoc specifying what exceptions are thrown when |
Comparing interface designs:
12345678910111213141516171819202122
// ❌ POOR DESIGNpublic interface CustomerOperations { // Too many unrelated operations Customer findById(CustomerId id); void save(Customer customer); void delete(CustomerId id); // Why is pricing here? Money calculateDiscounts(Customer c); List<Promotion> getActivePromotions(); // Why is notification here? void sendEmail(Customer c, String msg); void sendSms(Customer c, String msg); // Implementation detail leaked Connection getDbConnection(); // Inconsistent abstraction level List<Order> getOrders(CustomerId id); String getFullName(CustomerId id);}1234567891011121314151617181920212223242526
// ✅ GOOD DESIGN// Focused: only customer persistencepublic interface CustomerRepository { Customer findById(CustomerId id); List<Customer> findBySegment(Segment s); void save(Customer customer); void delete(CustomerId id);} // Focused: only discount logicpublic interface DiscountService { DiscountResult calculate( Customer customer, List<CartItem> items );} // Focused: only outbound messagingpublic interface NotificationService { void notify(Customer c, Notification n);} // Each interface:// - Does one thing well// - Is testable in isolation// - Can evolve independentlyThe principle of keeping interfaces focused is formalized as the Interface Segregation Principle (ISP)—the 'I' in SOLID. Clients should not be forced to depend on methods they don't use. Multiple focused interfaces are better than one large interface.
A powerful interface design technique is to think in terms of roles rather than types. Instead of asking "What can this object do?", ask "What role does this object play in this context?"
Example: A single entity with multiple roles
Consider a User object. In different contexts, it plays different roles:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Role: Something that can be authenticatedpublic interface AuthenticatedPrincipal { String getPrincipalId(); Set<Permission> getPermissions(); boolean hasPermission(Permission permission);} // Role: Something that receives notificationspublic interface Notifiable { ContactPreferences getContactPreferences(); String getEmail(); Optional<String> getPhoneNumber();} // Role: Something that can purchasepublic interface Purchaser { PaymentMethod getDefaultPaymentMethod(); List<PaymentMethod> getSavedPaymentMethods(); Address getDefaultShippingAddress();} // Role: Something whose activity can be auditedpublic interface Auditable { String getAuditIdentifier(); Instant getLastActivityTime();} // The User class implements all roles it can playpublic class User implements AuthenticatedPrincipal, Notifiable, Purchaser, Auditable { // Implementation for all roles} // Consumers depend only on the role they needpublic class SecurityService { public void checkAccess(AuthenticatedPrincipal principal, Resource resource) { if (!principal.hasPermission(resource.requiredPermission())) { throw new AccessDeniedException(); } } // SecurityService knows nothing about email, payment, etc.} public class NotificationService { public void send(Notifiable recipient, Message message) { ContactPreferences prefs = recipient.getContactPreferences(); if (prefs.prefersEmail()) { emailSender.send(recipient.getEmail(), message); } } // NotificationService knows nothing about permissions, payments, etc.} public class CheckoutService { public void checkout(Purchaser purchaser, Cart cart) { PaymentMethod method = purchaser.getDefaultPaymentMethod(); Address shipping = purchaser.getDefaultShippingAddress(); // CheckoutService knows nothing about permissions, notifications, etc. }}Benefits of role-based interfaces:
SecurityService doesn't know (or care) that principals have email addresses.User and ServiceAccount can implement AuthenticatedPrincipal. Both User and Organization can implement Notifiable.SubscriptionHolder doesn't impact Notifiable.Think of roles like costumes an actor wears. A single person (object) can play the doctor in one scene and the parent in another. The role defines what's relevant in that context. Methods should accept roles, not concrete types.
Interfaces can be combined and composed in powerful ways. Understanding these patterns helps you design more flexible systems:
Pattern 1: Interface Inheritance
Extend interfaces to add capabilities:
1234567891011121314151617181920212223242526272829303132333435
// Base capabilitypublic interface Repository<T, ID> { Optional<T> findById(ID id); T save(T entity); void delete(ID id);} // Extended capabilitypublic interface QueryableRepository<T, ID> extends Repository<T, ID> { List<T> findAll(); List<T> findBySpecification(Specification<T> spec); Page<T> findPage(PageRequest request);} // Further extendedpublic interface AuditableRepository<T, ID> extends Repository<T, ID> { List<T> findModifiedAfter(Instant timestamp); List<AuditLog> getHistory(ID id);} // Implementation can choose which level to supportpublic class UserRepository implements QueryableRepository<User, UserId> { // Must implement Repository + QueryableRepository methods} // Consumer can depend on what they needpublic class ReportingService { private final QueryableRepository<Order, OrderId> orders; // Can use findAll(), findBySpecification(), etc.} public class SyncService { private final AuditableRepository<Product, ProductId> products; // Can use findModifiedAfter() for incremental sync}Pattern 2: Interface Combination
Implement multiple independent interfaces:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Independent capabilitiespublic interface Startup { void start(); boolean isRunning();} public interface Shutdown { void shutdown(); void shutdownNow(); boolean awaitTermination(Duration timeout);} public interface HealthCheck { HealthStatus checkHealth();} // Combine for lifecycle-managed componentspublic interface ManagedService extends Startup, Shutdown, HealthCheck { // Inherits all methods from combined interfaces} // Consumer can work with any combinationpublic class ServiceManager { public void registerService(ManagedService service) { services.add(service); } public void startAll() { for (ManagedService service : services) { service.start(); } } public HealthReport checkAllHealth() { return services.stream() .map(s -> s.checkHealth()) .collect(toHealthReport()); }} // Or accept just what's neededpublic class HealthMonitor { public void register(HealthCheck checkable) { // Only needs health checking, not start/stop healthChecks.add(checkable); }}Pattern 3: Marker Interfaces
Signal capabilities without adding methods:
1234567891011121314151617181920212223242526272829
// Marker: Indicates safe for concurrent accesspublic interface ThreadSafe { // No methods - merely a marker} // Marker: Indicates idempotent operationspublic interface Idempotent { // No methods - signals retry safety} // Services can be tagged with markerspublic class PaymentProcessor implements PaymentGateway, Idempotent { // Operations can be safely retried} public class ConcurrentCache implements Cache, ThreadSafe { // Safe for multi-threaded access} // Generic code can check for markerspublic class RetryHandler { public <T> T executeWithRetry(Supplier<T> operation, Object service) { if (service instanceof Idempotent) { return executeWithRetries(operation, 3); } else { return operation.get(); // Single attempt only } }}In modern Java and other languages, marker interfaces are often replaced by annotations (@ThreadSafe, @Idempotent). Annotations are metadata rather than types, so they don't affect interface hierarchies. Both approaches work; choose based on your ecosystem's conventions.
The most powerful interface designs emerge from the domain—the problem space you're working in. Domain-driven interface design means interfaces reflect domain concepts, not technical implementation.
Contrast: Technical vs Domain Interfaces
12345678910111213141516171819
// ❌ TECHNICAL FOCUS// Named by technologypublic interface DatabaseOrderHandler { // Technical operations void insertRow(OrderDto dto); ResultSet queryByStatus( String status ); void executeTransfer( Connection conn, String sql ); // Technical return types List<Map<String, Object>> executeQuery(String sql);}12345678910111213141516171819
// ✅ DOMAIN FOCUS// Named by domain conceptpublic interface OrderRepository { // Domain operations Order save(Order order); Optional<Order> findById( OrderId id ); List<Order> findPending(); // Domain return types OrderSummary getSummary( OrderId id ); // Domain exceptions // throws OrderNotFoundException}Using Ubiquitous Language:
Domain-driven design emphasizes ubiquitous language—a shared vocabulary used by developers and domain experts. Interfaces should use this language:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Domain: E-commerce fulfillment// The business talks about "fulfilling orders" and "shipping" // ❌ Generic programming termspublic interface ItemHandler { void process(ItemDto item); void move(ItemDto from, LocationDto to);} // ✅ Domain terms the business usespublic interface FulfillmentService { void fulfillOrder(Order order); // Business action void shipToCustomer(Order order); // Business action PackingSlip generatePackingSlip(Order order);// Business artifact} // ❌ Technical storage termspublic interface DataStore { void insert(Object entity); List<Object> query(Predicate pred);} // ✅ Domain-specific repositorypublic interface ShipmentRepository { Shipment findByTrackingNumber(TrackingNumber number); List<Shipment> findInTransit(); List<Shipment> findDeliveredToday(); // Domain concept} // ❌ Generic messagingpublic interface MessageSender { void send(String recipient, String content);} // ✅ Domain-specific communicationpublic interface CustomerCommunication { void sendShippingNotification( Customer customer, Shipment shipment ); void sendDeliveryConfirmation( Customer customer, Shipment shipment, DeliveryProof proof );}If your interface names confuse domain experts, you're speaking the wrong language. Good interfaces sound natural when discussed with business stakeholders: 'The FulfillmentService ships orders to customers.' If you say 'The ItemHandler processes ItemDtos,' something is wrong.
With interfaces defined, implementation follows. Here's a complete example showing how interface-first design leads to clean, testable code:
The checkout system implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// The orchestrating service depends only on interfacespublic class CheckoutService { private final PricingService pricing; private final InventoryService inventory; private final PaymentGateway payments; private final OrderRepository orders; private final NotificationService notifications; // Constructor injection of all interfaces public CheckoutService( PricingService pricing, InventoryService inventory, PaymentGateway payments, OrderRepository orders, NotificationService notifications) { this.pricing = pricing; this.inventory = inventory; this.payments = payments; this.orders = orders; this.notifications = notifications; } public CheckoutResult checkout(Cart cart, Customer customer, ShippingOption shipping, PaymentMethod paymentMethod) { // 1. Calculate pricing PricingResult price = pricing.calculatePrice(cart, customer, shipping); // 2. Check & reserve inventory AvailabilityResult availability = inventory.checkAvailability(cart.getItems()); if (!availability.isFullyAvailable()) { return CheckoutResult.outOfStock(availability.getUnavailableItems()); } ReservationResult reservation = inventory.reserve( cart.getItems(), Duration.ofMinutes(10) ); try { // 3. Authorize payment PaymentResult payment = payments.authorize(price.total(), paymentMethod); if (!payment.isSuccessful()) { inventory.release(reservation.getId()); notifications.sendOrderFailed(customer, payment.getErrorMessage()); return CheckoutResult.paymentFailed(payment); } // 4. Create order Order order = Order.create( cart, customer, shipping, price, payment.getTransactionId() ); Order savedOrder = orders.save(order); // 5. Capture payment payments.capture(payment.getTransactionId()); // 6. Notify customer notifications.sendOrderConfirmation(savedOrder, customer); return CheckoutResult.success(savedOrder); } catch (Exception e) { // Compensating actions inventory.release(reservation.getId()); throw new CheckoutException("Checkout failed", e); } }} // Test with fake implementations - no external dependencies@Testvoid checkout_successPath_createsOrderAndNotifies() { // Arrange: Simple fakes FakePricingService pricing = new FakePricingService( PricingResult.of(Money.of(99.99, USD)) ); FakeInventoryService inventory = new FakeInventoryService( AvailabilityResult.fullyAvailable() ); FakePaymentGateway payments = new FakePaymentGateway( PaymentResult.success("TXN-123") ); InMemoryOrderRepository orders = new InMemoryOrderRepository(); RecordingNotificationService notifications = new RecordingNotificationService(); CheckoutService service = new CheckoutService( pricing, inventory, payments, orders, notifications ); // Act CheckoutResult result = service.checkout(cart, customer, shipping, card); // Assert assertThat(result.isSuccessful()).isTrue(); assertThat(orders.findAll()).hasSize(1); assertThat(notifications.getConfirmationsSent()).hasSize(1);}Notice how testing requires no mocking frameworks, no database, no payment gateway credentials. Simple fake implementations fulfill the interface contracts. The test runs in milliseconds and verifies business logic in isolation.
With interfaces designed and implemented, the final step is wiring—connecting implementations to consumers. This typically happens at application startup.
Common wiring approaches:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// ==========================================// Approach 1: Manual Wiring (Pure Java)// ==========================================public class Application { public static void main(String[] args) { // Create implementations PricingService pricing = new StandardPricingService( new PromotionRepository(), new TaxCalculator() ); InventoryService inventory = new WarehouseInventoryService( dataSource, redisClient ); PaymentGateway payments = new StripePaymentGateway( stripeApiKey, webhookSecret ); OrderRepository orders = new PostgresOrderRepository(dataSource); NotificationService notifications = new MultiChannelNotifier( new SendGridEmailer(apiKey), new TwilioSmsService(accountSid, authToken) ); // Wire together CheckoutService checkout = new CheckoutService( pricing, inventory, payments, orders, notifications ); // Start application with wired services new WebServer(checkout).start(); }} // ==========================================// Approach 2: Dependency Injection Framework (Spring)// ==========================================@Configurationpublic class CheckoutConfiguration { @Bean public PricingService pricingService( PromotionRepository promos, TaxCalculator tax) { return new StandardPricingService(promos, tax); } @Bean @Profile("production") public PaymentGateway productionPayments( @Value("${stripe.api.key}") String apiKey) { return new StripePaymentGateway(apiKey); } @Bean @Profile("development") public PaymentGateway developmentPayments() { return new FakePaymentGateway(); // No real charges } @Bean public CheckoutService checkoutService( PricingService pricing, InventoryService inventory, PaymentGateway payments, OrderRepository orders, NotificationService notifications) { return new CheckoutService( pricing, inventory, payments, orders, notifications ); }} // ==========================================// Approach 3: Module System (Guice-style)// ==========================================public class CheckoutModule extends AbstractModule { @Override protected void configure() { bind(PricingService.class).to(StandardPricingService.class); bind(PaymentGateway.class).to(StripePaymentGateway.class); bind(OrderRepository.class).to(PostgresOrderRepository.class); // Conditional binding if (isDevelopment()) { bind(NotificationService.class).to(LoggingNotifier.class); } else { bind(NotificationService.class).to(MultiChannelNotifier.class); } }}new across the codebase.We've completed our exploration of programming to interfaces with a comprehensive look at interface-based design. Let's consolidate everything we've learned across this module:
The principle in action:
"Program to an interface, not an implementation." — Gang of Four, Design Patterns (1994)
This principle, articulated over 30 years ago, remains one of the most powerful ideas in software design. It's the foundation of testable code, flexible architectures, and systems that can evolve gracefully.
As you continue your journey in system design, you'll see this principle appear again and again: in SOLID principles, in design patterns like Strategy and Observer, in architectural patterns like ports and adapters, and in modern practices like dependency injection. Master this concept, and you've mastered a cornerstone of professional software engineering.
Congratulations! You've completed the module on Programming to Interfaces. You now understand why to depend on abstractions, how interfaces enable flexibility and reduce coupling, and how to design systems where interfaces are first-class architectural elements. This knowledge will serve you throughout your career as you design flexible, testable, maintainable systems.