Skip to main content

The Deep OOP Principles: Why Interfaces Aren't Just About Code Reuse (Part 3)

· 11 min read
Mahmut Salman
Software Developer

In Parts 1 and 2, we learned what interfaces are and when to use them. Now let's dive deep into the OOP principles that make interfaces essential for large-scale applications. This isn't about avoiding code duplication—it's about fundamental software design.

The Core Problem Interfaces Solve

Interfaces solve a fundamental problem: How do you write code that works with things that don't exist yet?

The Payment System Example

Imagine you're building an e-commerce payment system today:

Available payment methods:

  • Credit Card
  • PayPal

Your code (without interfaces):

public class CheckoutService {

public void processPayment(String paymentType, double amount) {
if (paymentType.equals("creditCard")) {
// Credit card logic
validateCard();
chargeCreditCard(amount);
sendCreditCardReceipt();
} else if (paymentType.equals("paypal")) {
// PayPal logic
redirectToPayPal();
chargePayPal(amount);
sendPayPalReceipt();
}
}
}

Problems:

  1. ❌ Every new payment method requires modifying this class
  2. ❌ Violates Open/Closed Principle (open for extension, closed for modification)
  3. ❌ Hard to test (can't mock payment processors)
  4. ❌ Tight coupling (CheckoutService knows ALL payment details)

What happens next year when you add:

  • Apple Pay
  • Google Pay
  • Cryptocurrency
  • Buy Now Pay Later

You have to modify CheckoutService every time!

public void processPayment(String paymentType, double amount) {
if (paymentType.equals("creditCard")) {
// ...
} else if (paymentType.equals("paypal")) {
// ...
} else if (paymentType.equals("applePay")) { // ← Modify existing code!
// ...
} else if (paymentType.equals("googlePay")) { // ← Modify again!
// ...
} else if (paymentType.equals("crypto")) { // ← And again!
// ...
}
// This gets out of control!
}

The Solution: Interfaces as Contracts

The Interface = The Agreement

public interface PaymentProcessor {
void processPayment(double amount);
boolean refund(String transactionId);
}

This says: "Whatever implements me MUST have these methods. I don't care HOW you do it, just that you DO it."

Different Implementations

public class CreditCardProcessor implements PaymentProcessor {

@Override
public void processPayment(double amount) {
// Credit card-specific implementation
validateCardNumber();
checkCreditLimit(amount);
chargeThroughPaymentGateway(amount);
sendCreditCardReceipt();
}

@Override
public boolean refund(String transactionId) {
// Credit card refund logic
return refundThroughGateway(transactionId);
}
}

public class PayPalProcessor implements PaymentProcessor {

@Override
public void processPayment(double amount) {
// PayPal-specific implementation
redirectToPayPal();
authorizePayPalPayment(amount);
capturePayment();
sendPayPalReceipt();
}

@Override
public boolean refund(String transactionId) {
// PayPal refund logic
return paypalAPI.refund(transactionId);
}
}

public class CryptoProcessor implements PaymentProcessor {

@Override
public void processPayment(double amount) {
// Crypto-specific implementation
generateWalletAddress();
waitForBlockchainConfirmation();
processTransaction(amount);
}

@Override
public boolean refund(String transactionId) {
// Crypto refund logic (if supported)
return false; // Crypto payments might not support refunds
}
}

Notice: Each implementation is completely different internally, but they all fulfill the same contract.

Your Code Now Works with ALL of Them

public class CheckoutService {

// Depends on INTERFACE, not concrete class
private PaymentProcessor paymentProcessor;

public CheckoutService(PaymentProcessor processor) {
this.paymentProcessor = processor;
}

public void checkout(double amount) {
// Works with ANY implementation!
paymentProcessor.processPayment(amount);
}
}

Usage:

// Credit card
CheckoutService checkout = new CheckoutService(new CreditCardProcessor());
checkout.checkout(100.00);

// PayPal
checkout = new CheckoutService(new PayPalProcessor());
checkout.checkout(100.00);

// Crypto
checkout = new CheckoutService(new CryptoProcessor());
checkout.checkout(100.00);

// Future payment method (doesn't exist yet!)
checkout = new CheckoutService(new BuyNowPayLaterProcessor());
checkout.checkout(100.00);

Your CheckoutService code never changed!


Dependency Inversion Principle (SOLID)

This is the "D" in SOLID principles.

The Principle

"Depend on abstractions, not concretions"

What this means:

  • High-level code should NOT depend on low-level implementation details
  • Both should depend on abstractions (interfaces)

Without Interfaces (Tight Coupling)

public class UserService {
// Depends on CONCRETE class (bad!)
private MySQLUserRepository repo = new MySQLUserRepository();

public void saveUser(User user) {
repo.saveToMySQL(user); // Tightly coupled to MySQL
}
}

Problems:

UserService → MySQLUserRepository → MySQL Database
↑ ↑ ↑
High-level Low-level Implementation
detail detail

If you switch to PostgreSQL:

public class UserService {
// Have to change this line!
private PostgreSQLUserRepository repo = new PostgreSQLUserRepository();

public void saveUser(User user) {
// Have to change this line!
repo.saveToPostgreSQL(user);
}
}

You have to rewrite UserService!

With Interfaces (Loose Coupling)

public class UserService {
// Depends on INTERFACE (good!)
private UserRepository repo;

public UserService(UserRepository repo) {
this.repo = repo;
}

public void saveUser(User user) {
repo.save(user); // Works with ANY implementation
}
}

The dependency flow:

       Interface (Abstraction)
↑ ↑
| |
UserService MySQLUserRepository
(High-level) (Low-level)

Both depend on the interface!

Switch to PostgreSQL:

// UserService code stays EXACTLY the same!
// Just change what gets injected:
UserRepository repo = new PostgreSQLUserRepository();
UserService service = new UserService(repo);

No changes to UserService!


Why Spring Data JPA MUST Use Interfaces

The Genius of Spring Data JPA

Your declaration:

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByAgeGreaterThan(int age);
}

What you're saying:

"I promise that whoever uses UserRepository will have these methods. I don't care HOW they work, just that they exist."

What Spring does (at runtime):

// 1. Spring sees: UserRepository extends JpaRepository
System.out.println("Found repository interface: UserRepository");

// 2. Spring analyzes method names
System.out.println("Method: findByEmail");
System.out.println(" → Generated SQL: SELECT * FROM users WHERE email = ?");

System.out.println("Method: findByAgeGreaterThan");
System.out.println(" → Generated SQL: SELECT * FROM users WHERE age > ?");

// 3. Spring creates a PROXY (fake implementation)
class SpringGeneratedProxy implements UserRepository {

private EntityManager em;

@Override
public Optional<User> findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email)
.getResultStream()
.findFirst();
}

@Override
public List<User> findByAgeGreaterThan(int age) {
return em.createQuery("SELECT u FROM User u WHERE u.age > :age", User.class)
.setParameter("age", age)
.getResultList();
}

// Plus ALL JpaRepository methods (20+)
}

// 4. Spring registers this proxy in the application context
UserRepository userRepository = new SpringGeneratedProxy();

You never wrote ANY of this code - Spring generated it all!

If UserRepository Was a Class (Breaks the Magic)

// ❌ WRONG - Won't work!
public class UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // No implementation!
}

Problems:

  1. ❌ Spring can't create a proxy from a class (proxies work on interfaces)

  2. ❌ You'd have to implement ALL methods yourself:

    @Override
    public Optional<User> findByEmail(String email) {
    // You write this... how?
    // You need EntityManager, SQL knowledge, etc.
    }

    @Override
    public User save(User user) {
    // You implement this...
    }

    @Override
    public Optional<User> findById(Long id) {
    // And this...
    }

    // ... 20+ more methods!
  3. ❌ The whole point of Spring Data JPA is automatic implementation!


Dependency Inversion in Action

Bad Design (Violates Dependency Inversion)

// High-level module
public class OrderService {

// Depends on CONCRETE low-level class
private MySQLDatabase database = new MySQLDatabase();

public void createOrder(Order order) {
database.insertIntoMySQL(order); // Tightly coupled!
}
}

// Low-level module
public class MySQLDatabase {
public void insertIntoMySQL(Order order) {
// MySQL-specific code
}
}

Dependency graph:

OrderService → MySQLDatabase → MySQL
(High-level) (Low-level) (Database)

Problems:

  • Can't test OrderService without MySQL
  • Can't switch databases
  • OrderService knows MySQL implementation details

Good Design (Follows Dependency Inversion)

// Abstraction (interface)
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(Long id);
}

// High-level module
public class OrderService {

// Depends on INTERFACE
private OrderRepository repository;

public OrderService(OrderRepository repository) {
this.repository = repository;
}

public void createOrder(Order order) {
repository.save(order); // Works with ANY implementation!
}
}

// Low-level module (MySQL implementation)
public class MySQLOrderRepository implements OrderRepository {

@Override
public void save(Order order) {
// MySQL-specific code
}

@Override
public Optional<Order> findById(Long id) {
// MySQL query
}
}

// Low-level module (PostgreSQL implementation)
public class PostgreSQLOrderRepository implements OrderRepository {

@Override
public void save(Order order) {
// PostgreSQL-specific code
}

@Override
public Optional<Order> findById(Long id) {
// PostgreSQL query
}
}

Dependency graph:

              OrderRepository (Interface)
↑ ↑
| |
OrderService MySQLOrderRepository
(High-level) (Low-level)

Benefits:

  • ✅ Can test with mock repository
  • ✅ Can switch databases
  • OrderService knows nothing about MySQL/PostgreSQL

When Interfaces Shine: The Special Occasions

Interfaces are essential when you have:

1. Multiple Implementations with Same Contract

Example: Different payment methods

public interface PaymentProcessor {
boolean process(double amount);
}

// All implement same contract, but work differently
public class CreditCardProcessor implements PaymentProcessor { }
public class PayPalProcessor implements PaymentProcessor { }
public class StripeProcessor implements PaymentProcessor { }
public class CryptoProcessor implements PaymentProcessor { }

2. Need for Substitution

Example: Swap implementations without breaking code

@Service
public class CheckoutService {

@Autowired // Spring can inject ANY implementation
private PaymentProcessor processor;

public void checkout(Order order) {
processor.process(order.getTotal());
}
}

// Configuration decides which implementation:
@Configuration
public class PaymentConfig {

@Bean
public PaymentProcessor paymentProcessor() {
// Easy to switch!
// return new CreditCardProcessor();
// return new PayPalProcessor();
return new StripeProcessor();
}
}

3. Framework Magic Needed

Example: Spring needs to intercept method calls

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}

// Spring creates proxy that:
// - Generates SQL from method name
// - Manages transactions
// - Handles exceptions
// - Caches results
// - Logs queries
// All transparent to you!

4. Decoupling Goals

Example: High-level code shouldn't know low-level details

// High-level: Order processing logic
public class OrderService {
private OrderRepository repository; // Interface!
private EmailService emailService; // Interface!
private PaymentProcessor processor; // Interface!

// Knows NOTHING about:
// - Which database (MySQL? PostgreSQL? MongoDB?)
// - How emails are sent (SMTP? SendGrid? AWS SES?)
// - How payments work (Stripe? PayPal? Crypto?)
}

Real-World Benefits for Large Systems

Benefit 1: Testing

Without interfaces:

public class UserService {
private MySQLUserRepository repo = new MySQLUserRepository();

public User registerUser(String email) {
return repo.saveToMySQL(new User(email));
}
}

// Testing requires actual MySQL database!
@Test
public void testRegisterUser() {
// Need to:
// - Start MySQL
// - Create test database
// - Clean up after test
// Very slow!
}

With interfaces:

public class UserService {
private UserRepository repo;

public UserService(UserRepository repo) {
this.repo = repo;
}

public User registerUser(String email) {
return repo.save(new User(email));
}
}

// Testing with mock!
@Test
public void testRegisterUser() {
// Create fake implementation
UserRepository mockRepo = new UserRepository() {
@Override
public User save(User user) {
user.setId(1L);
return user;
}
};

UserService service = new UserService(mockRepo);
User user = service.registerUser("test@email.com");

assertEquals(1L, user.getId());
// No database needed! Fast tests!
}

Benefit 2: Flexibility

Switch implementations without touching service code:

// Development: Use H2 in-memory database
@Profile("dev")
@Bean
public UserRepository userRepository() {
return new H2UserRepository();
}

// Production: Use PostgreSQL
@Profile("prod")
@Bean
public UserRepository userRepository() {
return new PostgreSQLUserRepository();
}

// UserService code doesn't change!

Benefit 3: Scalability

Change implementation details transparently:

// Version 1: Simple database
public class UserRepositoryImpl implements UserRepository {
@Override
public User save(User user) {
return database.save(user);
}
}

// Version 2: Add caching (no API changes!)
public class CachedUserRepository implements UserRepository {
private Cache cache;
private Database database;

@Override
public User save(User user) {
User saved = database.save(user);
cache.put(saved.getId(), saved); // Add caching
return saved;
}
}

// Existing code using UserRepository still works!

Benefit 4: Team Collaboration

Different teams implement different repositories:

// Team A: User management
public interface UserRepository extends JpaRepository<User, Long> { }

// Team B: Product catalog
public interface ProductRepository extends JpaRepository<Product, Long> { }

// Team C: Order processing
public interface OrderRepository extends JpaRepository<Order, Long> { }

// All follow same pattern (interface)
// Teams work independently
// No conflicts!

Benefit 5: Framework Magic

Spring can add cross-cutting concerns transparently:

public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
}

// Spring's proxy adds:
// - @Transactional behavior (automatic transaction management)
// - Exception translation (SQLException → DataAccessException)
// - Query caching
// - Performance monitoring
// - Security checks
// All without you writing any code!

The Complete Picture: SOLID Principles

Why Interfaces Support All SOLID Principles

S - Single Responsibility Principle

// Interface has ONE responsibility: define data access contract
public interface UserRepository extends JpaRepository<User, Long> { }

// Implementation has ONE responsibility: actually access data
public class JpaUserRepositoryImpl implements UserRepository { }

O - Open/Closed Principle

// Open for extension (add new implementations)
public class CreditCardProcessor implements PaymentProcessor { }
public class PayPalProcessor implements PaymentProcessor { }

// Closed for modification (PaymentProcessor interface doesn't change)
public interface PaymentProcessor {
boolean process(double amount);
}

L - Liskov Substitution Principle

// Any PaymentProcessor implementation can substitute for another
PaymentProcessor processor = new CreditCardProcessor();
processor.process(100); // Works

processor = new PayPalProcessor();
processor.process(100); // Still works!

I - Interface Segregation Principle

// Many small interfaces, not one giant interface

// Bad: One giant interface
public interface UserOperations {
User save(User user);
User findById(Long id);
void delete(User user);
void sendEmail(User user); // Email operations mixed in
void processPayment(User user); // Payment operations mixed in
}

// Good: Segregated interfaces
public interface UserRepository {
User save(User user);
User findById(Long id);
void delete(User user);
}

public interface EmailService {
void sendEmail(User user);
}

public interface PaymentService {
void processPayment(User user);
}

D - Dependency Inversion Principle

// High-level modules depend on interfaces, not concrete classes
public class OrderService {
private OrderRepository repository; // Interface!
private PaymentProcessor processor; // Interface!
private EmailService emailService; // Interface!
}

Summary

Interfaces Are NOT Just About Code Reuse

They're about:

  1. Contracts - Promises of what capabilities exist
  2. Flexibility - Swap implementations without breaking code
  3. Testability - Mock dependencies for fast unit tests
  4. Decoupling - High-level code independent of low-level details
  5. Framework Magic - Enable Spring's automatic implementation

The Dependency Inversion Principle

Bad (Tight Coupling):

High-level → Low-level (Concrete Class)

Good (Loose Coupling):

High-level → Interface ← Low-level

Why Spring Data JPA Needs Interfaces

You write:     Interface (contract)

Spring writes: Proxy (implementation)

You get: Fully working repository with zero SQL!

This only works because:

  • Proxies can only be created from interfaces
  • You declare WHAT you want, Spring implements HOW
  • Your code depends on contract, not implementation

The Big Insight

Interfaces enable:

  • Writing code that works with things that don't exist yet
  • Swapping implementations without breaking existing code
  • Testing without real dependencies
  • Framework magic (Spring generates implementations)

This isn't just theory - it's how professional software is built! 🎯


Key Takeaway: Interfaces are the foundation of flexible, testable, maintainable code. They're not about avoiding duplication—they're about depending on abstractions instead of concretions, which is the cornerstone of good OOP design. 🏗️

Tags: #java #interfaces #oop #solid #design-patterns #dependency-inversion #spring-boot