Skip to main content

Interfaces Beyond Frameworks: 7 Real-World Scenarios You'll Actually Use

· 18 min read
Mahmut Salman
Software Developer

"Interfaces are for frameworks, right?" Wrong! While frameworks love interfaces, they're solving a much broader problem. Here are 7 real-world scenarios where YOU will use interfaces—no framework required.

The Observation​

"In college, I heard that interfaces let you dictate that subordinates implement certain functions. But in real-life, I feel like interfaces are mainly used with frameworks. Frameworks and interfaces are like siblings to me. Am I correct?"

You're 80% right about frameworks loving interfaces, but interfaces solve much broader problems!

Let me show you concrete real-world scenarios where you will use interfaces, framework or not.


The Reality Check​

What You Observed​

Your Experience:
Spring Data → Interface
Frameworks → Interface
Payment SDKs → Interface

Conclusion: Interfaces = Framework thing?

The Truth​

Actual Reality:
Interfaces are a FUNDAMENTAL TOOL
Frameworks just use them really well

When you need:
- Multiple ways to do something
- Code that doesn't care about details
- Pluggable behavior
- Team collaboration
- Testing flexibility

→ You use interfaces (framework or not!)

Scenario 1: Plugin Systems (No Framework!)​

The Problem​

You're building a video editor (like Adobe Premiere or DaVinci Resolve).

Challenge: Users want to add custom effects, but you can't predict what effects they'll create.

The Solution: Interface as Plugin Contract​

public interface VideoEffect {
BufferedImage apply(BufferedImage frame, Map<String, Object> parameters);
String getEffectName();
String getDescription();
Map<String, EffectParameter> getParameters();
}

This says: "If you want to create an effect plugin, implement these methods."

Third-Party Developers Create Plugins​

// Plugin 1: Blur Effect
public class BlurEffect implements VideoEffect {

@Override
public BufferedImage apply(BufferedImage frame, Map<String, Object> params) {
int blurRadius = (int) params.get("radius");
// Apply Gaussian blur algorithm
return applyGaussianBlur(frame, blurRadius);
}

@Override
public String getEffectName() {
return "Gaussian Blur";
}

@Override
public String getDescription() {
return "Applies a smooth blur effect to the video frame";
}

@Override
public Map<String, EffectParameter> getParameters() {
Map<String, EffectParameter> params = new HashMap<>();
params.put("radius", new EffectParameter("Blur Radius", 0, 50, 10));
return params;
}
}

// Plugin 2: Neon Glow Effect
public class NeonGlowEffect implements VideoEffect {

@Override
public BufferedImage apply(BufferedImage frame, Map<String, Object> params) {
Color glowColor = (Color) params.get("color");
int intensity = (int) params.get("intensity");
// Apply neon glow algorithm
return applyNeonGlow(frame, glowColor, intensity);
}

@Override
public String getEffectName() {
return "Neon Glow";
}

@Override
public String getDescription() {
return "Creates a vibrant neon glow around bright areas";
}

@Override
public Map<String, EffectParameter> getParameters() {
Map<String, EffectParameter> params = new HashMap<>();
params.put("color", new EffectParameter("Glow Color", Color.CYAN));
params.put("intensity", new EffectParameter("Intensity", 0, 100, 50));
return params;
}
}

// Plugin 3: Vintage Filter
public class VintageFilterEffect implements VideoEffect {

@Override
public BufferedImage apply(BufferedImage frame, Map<String, Object> params) {
int warmth = (int) params.get("warmth");
int grain = (int) params.get("grain");
// Apply vintage film look
return applyVintageFilter(frame, warmth, grain);
}

@Override
public String getEffectName() {
return "Vintage Film";
}

@Override
public String getDescription() {
return "Gives your video a classic vintage film aesthetic";
}

@Override
public Map<String, EffectParameter> getParameters() {
Map<String, EffectParameter> params = new HashMap<>();
params.put("warmth", new EffectParameter("Warmth", 0, 100, 70));
params.put("grain", new EffectParameter("Film Grain", 0, 100, 30));
return params;
}
}

Your Video Editor Loads Plugins Dynamically​

public class EffectManager {

private List<VideoEffect> loadedEffects = new ArrayList<>();

public void loadPluginsFromFolder(String pluginFolder) {
File folder = new File(pluginFolder);
for (File file : folder.listFiles()) {
if (file.getName().endsWith(".jar")) {
// Load JAR and find classes implementing VideoEffect
VideoEffect effect = loadEffectFromJar(file);
loadedEffects.add(effect);
}
}
}

public BufferedImage applyEffect(String effectName, BufferedImage frame,
Map<String, Object> parameters) {
// Find the effect
VideoEffect effect = loadedEffects.stream()
.filter(e -> e.getEffectName().equals(effectName))
.findFirst()
.orElseThrow(() -> new EffectNotFoundException(effectName));

// Apply it
return effect.apply(frame, parameters);
}

public List<String> getAvailableEffects() {
return loadedEffects.stream()
.map(VideoEffect::getEffectName)
.collect(Collectors.toList());
}
}

The magic: Your app doesn't know what plugins will exist! The interface lets third-party developers extend your app.

Real examples:

  • Photoshop plugins (filters, tools)
  • VS Code extensions
  • Minecraft mods
  • Chrome extensions

Scenario 2: Multi-Team Development​

The Problem​

Large company, e-commerce platform, 5 teams working simultaneously:

Team A: Payment System
Team B: Shopping Cart
Team C: User Accounts
Team D: Inventory
Team E: Reporting

Team B (Shopping Cart) needs to charge customers, but Team A (Payment) isn't done yet!

The Solution: Interface Contract​

Team A defines the contract (interface) upfront:

package com.company.payment;

public interface PaymentProcessor {
PaymentResult processPayment(Order order, PaymentDetails details);
PaymentResult refund(String transactionId, double amount);
boolean supportsPaymentMethod(String method);
PaymentStatus checkStatus(String transactionId);
}

public class PaymentResult {
private boolean success;
private String transactionId;
private String errorMessage;

// Constructor, getters, setters
}

public enum PaymentStatus {
PENDING, COMPLETED, FAILED, REFUNDED
}

Team B immediately builds against the interface:

package com.company.cart;

public class CheckoutService {

private PaymentProcessor paymentProcessor; // Interface!

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

public CheckoutResult checkout(ShoppingCart cart, PaymentDetails details) {
// Calculate total
Order order = createOrderFromCart(cart);

// Process payment (works even though Team A isn't done!)
PaymentResult paymentResult = paymentProcessor.processPayment(order, details);

if (paymentResult.isSuccess()) {
// Complete order
confirmOrder(order, paymentResult.getTransactionId());
return new CheckoutResult(true, order.getId());
} else {
// Handle failure
return new CheckoutResult(false, paymentResult.getErrorMessage());
}
}
}

Team B uses a mock for development/testing:

package com.company.cart.test;

public class MockPaymentProcessor implements PaymentProcessor {

@Override
public PaymentResult processPayment(Order order, PaymentDetails details) {
// Simulate successful payment
return new PaymentResult(true, "MOCK_" + UUID.randomUUID(), null);
}

@Override
public PaymentResult refund(String transactionId, double amount) {
return new PaymentResult(true, transactionId, null);
}

@Override
public boolean supportsPaymentMethod(String method) {
return true; // Mock supports everything
}

@Override
public PaymentStatus checkStatus(String transactionId) {
return PaymentStatus.COMPLETED;
}
}

// Team B's tests work immediately:
@Test
public void testCheckout() {
PaymentProcessor mockPayment = new MockPaymentProcessor();
CheckoutService checkout = new CheckoutService(mockPayment);

CheckoutResult result = checkout.checkout(cart, paymentDetails);
assertTrue(result.isSuccess());
}

Later, Team A finishes their implementation:

package com.company.payment;

public class StripePaymentProcessor implements PaymentProcessor {

private StripeAPI stripeClient;

@Override
public PaymentResult processPayment(Order order, PaymentDetails details) {
try {
// Actual Stripe API call
Charge charge = stripeClient.charges().create(ChargeCreateParams.builder()
.setAmount((long) (order.getTotal() * 100))
.setCurrency("usd")
.setSource(details.getCardToken())
.build());

return new PaymentResult(true, charge.getId(), null);

} catch (StripeException e) {
return new PaymentResult(false, null, e.getMessage());
}
}

// Other methods...
}

Swap implementations - Team B's code doesn't change:

// Development/Testing:
PaymentProcessor processor = new MockPaymentProcessor();

// Production:
PaymentProcessor processor = new StripePaymentProcessor(stripeConfig);

// Team B's CheckoutService works with BOTH!
CheckoutService checkout = new CheckoutService(processor);

Benefits:

  • ✅ Teams work independently
  • ✅ No blocking (Team B doesn't wait for Team A)
  • ✅ Easy testing (mock implementations)
  • ✅ Flexible production (swap real implementations)

Scenario 3: Strategy Pattern (Runtime Algorithm Switching)​

The Problem​

E-commerce product listing with multiple sorting options:

  • Price: Low to High
  • Price: High to Low
  • Rating: Best First
  • Newest First
  • Most Popular

Bad approach (hard to maintain):

public class ProductService {

public List<Product> getSortedProducts(String sortBy) {
if (sortBy.equals("price_asc")) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice))
.collect(Collectors.toList());

} else if (sortBy.equals("price_desc")) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice).reversed())
.collect(Collectors.toList());

} else if (sortBy.equals("rating")) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getRating).reversed())
.collect(Collectors.toList());

} else if (sortBy.equals("newest")) {
return products.stream()
.sorted(Comparator.comparing(Product::getCreatedDate).reversed())
.collect(Collectors.toList());

} else if (sortBy.equals("popular")) {
return products.stream()
.sorted(Comparator.comparingInt(Product::getViewCount).reversed())
.collect(Collectors.toList());
}

// Every new sorting option requires modifying this method!
return products;
}
}

Problems:

  • ❌ Every new sorting option modifies this class
  • ❌ Violates Open/Closed Principle
  • ❌ Hard to test individual sorting algorithms
  • ❌ Can't add sorting at runtime

The Solution: Strategy Pattern with Interface​

public interface SortingStrategy {
List<Product> sort(List<Product> products);
}

public class PriceLowToHighSort implements SortingStrategy {
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice))
.collect(Collectors.toList());
}
}

public class PriceHighToLowSort implements SortingStrategy {
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice).reversed())
.collect(Collectors.toList());
}
}

public class RatingSort implements SortingStrategy {
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getRating).reversed())
.collect(Collectors.toList());
}
}

public class NewestFirstSort implements SortingStrategy {
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparing(Product::getCreatedDate).reversed())
.collect(Collectors.toList());
}
}

public class PopularitySort implements SortingStrategy {
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingInt(Product::getViewCount).reversed())
.collect(Collectors.toList());
}
}

ProductService now doesn't care about sorting details:

public class ProductService {

private Map<String, SortingStrategy> strategies = new HashMap<>();

public ProductService() {
// Register strategies
strategies.put("price_asc", new PriceLowToHighSort());
strategies.put("price_desc", new PriceHighToLowSort());
strategies.put("rating", new RatingSort());
strategies.put("newest", new NewestFirstSort());
strategies.put("popular", new PopularitySort());
}

public List<Product> getSortedProducts(String sortBy) {
SortingStrategy strategy = strategies.getOrDefault(sortBy, products -> products);
return strategy.sort(products);
}

// Easy to add new sorting at runtime!
public void registerSortingStrategy(String name, SortingStrategy strategy) {
strategies.put(name, strategy);
}
}

Usage:

ProductService service = new ProductService();

// User selects sorting
List<Product> products = service.getSortedProducts("price_asc");

// Add custom sorting at runtime!
service.registerSortingStrategy("discount", new DiscountSort());
products = service.getSortedProducts("discount");

Benefits:

  • ✅ Easy to add new sorting (no modification to ProductService)
  • ✅ Each strategy is testable independently
  • ✅ Can add strategies at runtime
  • ✅ Clean, maintainable code

Scenario 4: Event System (Observer Pattern)​

The Problem​

Game development - multiple systems need to react to player death:

  • Audio system: Play death sound
  • UI system: Show game over screen
  • Statistics system: Save death to database
  • Achievement system: Check for death-related achievements
  • Particle system: Show explosion effect

Bad approach:

public class Player {

public void die() {
// Player dies
this.health = 0;

// Notify all systems (tightly coupled!)
AudioSystem.playSound("death_scream.wav");
UISystem.showGameOverScreen();
StatisticsSystem.recordDeath();
AchievementSystem.checkDeathAchievements();
ParticleSystem.explode(this.position);

// Every new system requires modifying this method!
}
}

Problems:

  • ❌ Player class knows about ALL systems
  • ❌ Adding new system requires modifying Player
  • ❌ Can't dynamically add/remove systems
  • ❌ Tight coupling

The Solution: Observer Pattern with Interface​

public interface GameEventListener {
void onPlayerDeath(PlayerDeathEvent event);
void onPlayerLevelUp(PlayerLevelUpEvent event);
void onEnemyKilled(EnemyKilledEvent event);
}

public class PlayerDeathEvent {
private Player player;
private String causeOfDeath;
private Vector3 location;

// Constructor, getters
}

Different systems implement the listener:

public class AudioSystem implements GameEventListener {

@Override
public void onPlayerDeath(PlayerDeathEvent event) {
playSound("death_scream.wav");
fadeOutMusic();
}

@Override
public void onPlayerLevelUp(PlayerLevelUpEvent event) {
playSound("level_up.wav");
}

@Override
public void onEnemyKilled(EnemyKilledEvent event) {
playSound("enemy_death.wav");
}
}

public class UISystem implements GameEventListener {

@Override
public void onPlayerDeath(PlayerDeathEvent event) {
showGameOverScreen(event.getCauseOfDeath());
showDeathStatistics();
}

@Override
public void onPlayerLevelUp(PlayerLevelUpEvent event) {
showLevelUpAnimation(event.getNewLevel());
}

@Override
public void onEnemyKilled(EnemyKilledEvent event) {
updateScore(event.getScoreGained());
}
}

public class StatisticsSystem implements GameEventListener {

@Override
public void onPlayerDeath(PlayerDeathEvent event) {
saveToDatabase(new DeathRecord(
event.getPlayer().getId(),
event.getCauseOfDeath(),
event.getLocation()
));
}

@Override
public void onPlayerLevelUp(PlayerLevelUpEvent event) {
updatePlayerLevel(event.getPlayer().getId(), event.getNewLevel());
}

@Override
public void onEnemyKilled(EnemyKilledEvent event) {
incrementKillCount(event.getEnemyType());
}
}

public class AchievementSystem implements GameEventListener {

@Override
public void onPlayerDeath(PlayerDeathEvent event) {
checkAchievement("DIE_FIRST_TIME");
checkAchievement("DIE_100_TIMES");
}

@Override
public void onPlayerLevelUp(PlayerLevelUpEvent event) {
checkAchievement("REACH_LEVEL_10");
checkAchievement("REACH_MAX_LEVEL");
}

@Override
public void onEnemyKilled(EnemyKilledEvent event) {
checkAchievement("KILL_FIRST_ENEMY");
}
}

Event manager handles notification:

public class EventManager {

private List<GameEventListener> listeners = new ArrayList<>();

public void registerListener(GameEventListener listener) {
listeners.add(listener);
}

public void unregisterListener(GameEventListener listener) {
listeners.remove(listener);
}

public void notifyPlayerDeath(PlayerDeathEvent event) {
for (GameEventListener listener : listeners) {
listener.onPlayerDeath(event);
}
}

public void notifyPlayerLevelUp(PlayerLevelUpEvent event) {
for (GameEventListener listener : listeners) {
listener.onPlayerLevelUp(event);
}
}
}

Player class is now decoupled:

public class Player {

private EventManager eventManager;

public void die(String cause) {
this.health = 0;

// Just notify - doesn't know what happens next!
PlayerDeathEvent event = new PlayerDeathEvent(this, cause, this.position);
eventManager.notifyPlayerDeath(event);
}
}

Game initialization:

public class Game {

public void initialize() {
EventManager eventManager = new EventManager();

// Register all systems
eventManager.registerListener(new AudioSystem());
eventManager.registerListener(new UISystem());
eventManager.registerListener(new StatisticsSystem());
eventManager.registerListener(new AchievementSystem());
eventManager.registerListener(new ParticleSystem());

// Easy to add new systems!
eventManager.registerListener(new StreamingSystem()); // For Twitch integration
}
}

Benefits:

  • ✅ Player class doesn't know about systems
  • ✅ Easy to add/remove systems
  • ✅ Systems can be enabled/disabled at runtime
  • ✅ Loose coupling

Scenario 5: File Storage Abstraction​

The Problem​

Your app needs to store files, but you might use:

  • Development: Local filesystem
  • Testing: In-memory storage
  • Production: AWS S3
  • Alternative: Google Cloud Storage, Azure Blob

You don't want business logic to care!

The Solution: Storage Interface​

public interface FileStorage {
void upload(String path, byte[] data);
void upload(String path, InputStream stream);
byte[] download(String path);
InputStream downloadStream(String path);
void delete(String path);
boolean exists(String path);
List<String> listFiles(String directory);
long getSize(String path);
}

Local filesystem implementation:

public class LocalFileStorage implements FileStorage {

private String baseDirectory;

public LocalFileStorage(String baseDirectory) {
this.baseDirectory = baseDirectory;
}

@Override
public void upload(String path, byte[] data) {
File file = new File(baseDirectory, path);
file.getParentFile().mkdirs();

try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(data);
} catch (IOException e) {
throw new StorageException("Failed to upload file", e);
}
}

@Override
public byte[] download(String path) {
File file = new File(baseDirectory, path);
try {
return Files.readAllBytes(file.toPath());
} catch (IOException e) {
throw new StorageException("Failed to download file", e);
}
}

@Override
public void delete(String path) {
File file = new File(baseDirectory, path);
file.delete();
}

@Override
public boolean exists(String path) {
return new File(baseDirectory, path).exists();
}

// Other methods...
}

AWS S3 implementation:

public class S3FileStorage implements FileStorage {

private AmazonS3 s3Client;
private String bucketName;

public S3FileStorage(AmazonS3 s3Client, String bucketName) {
this.s3Client = s3Client;
this.bucketName = bucketName;
}

@Override
public void upload(String path, byte[] data) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(data.length);

s3Client.putObject(
bucketName,
path,
new ByteArrayInputStream(data),
metadata
);
}

@Override
public byte[] download(String path) {
S3Object object = s3Client.getObject(bucketName, path);
try (InputStream is = object.getObjectContent()) {
return is.readAllBytes();
} catch (IOException e) {
throw new StorageException("Failed to download from S3", e);
}
}

@Override
public void delete(String path) {
s3Client.deleteObject(bucketName, path);
}

@Override
public boolean exists(String path) {
return s3Client.doesObjectExist(bucketName, path);
}

// Other methods...
}

In-memory implementation (for testing):

public class InMemoryFileStorage implements FileStorage {

private Map<String, byte[]> files = new ConcurrentHashMap<>();

@Override
public void upload(String path, byte[] data) {
files.put(path, data.clone());
}

@Override
public byte[] download(String path) {
byte[] data = files.get(path);
if (data == null) {
throw new FileNotFoundException(path);
}
return data.clone();
}

@Override
public void delete(String path) {
files.remove(path);
}

@Override
public boolean exists(String path) {
return files.containsKey(path);
}

// Other methods...
}

Business logic doesn't care:

public class DocumentService {

private FileStorage storage; // Interface!

public DocumentService(FileStorage storage) {
this.storage = storage;
}

public void saveDocument(Document document) {
String path = "documents/" + document.getId() + ".pdf";
storage.upload(path, document.getBytes());

document.setStoragePath(path);
document.setStoredAt(LocalDateTime.now());
}

public byte[] getDocument(String documentId) {
String path = "documents/" + documentId + ".pdf";
return storage.download(path);
}

public void deleteDocument(String documentId) {
String path = "documents/" + documentId + ".pdf";
storage.delete(path);
}
}

Configuration decides implementation:

@Configuration
public class StorageConfig {

@Bean
@Profile("dev")
public FileStorage localFileStorage() {
return new LocalFileStorage("./storage");
}

@Bean
@Profile("test")
public FileStorage testFileStorage() {
return new InMemoryFileStorage();
}

@Bean
@Profile("prod")
public FileStorage productionFileStorage(AmazonS3 s3Client) {
return new S3FileStorage(s3Client, "my-app-production");
}
}

Benefits:

  • ✅ Switch storage without touching business logic
  • ✅ Fast tests (in-memory storage)
  • ✅ Easy local development (local filesystem)
  • ✅ Production-ready (S3)
  • ✅ Can add Google Cloud/Azure later

Scenario 6: API/Service Contracts (Backend + Mobile)​

The Problem​

Mobile app team and backend team working in parallel.

Challenge: Mobile team can't wait for backend to finish!

The Solution: Shared Interface Contract​

Shared module (both teams depend on this):

package com.company.api;

public interface UserService {
User getUserById(Long id);
User createUser(UserCreateRequest request);
void updateUser(Long id, UserUpdateRequest request);
void deleteUser(Long id);
List<User> searchUsers(String query);
boolean emailExists(String email);
}

public class User {
private Long id;
private String username;
private String email;
private String profilePictureUrl;
// Getters, setters
}

public class UserCreateRequest {
private String username;
private String email;
private String password;
// Getters, setters
}

Mobile team uses mock implementation:

package com.company.mobile;

public class MockUserService implements UserService {

private Map<Long, User> users = new HashMap<>();
private AtomicLong idGenerator = new AtomicLong(1);

@Override
public User getUserById(Long id) {
User user = users.get(id);
if (user == null) {
throw new UserNotFoundException("User not found: " + id);
}
return user;
}

@Override
public User createUser(UserCreateRequest request) {
User user = new User();
user.setId(idGenerator.getAndIncrement());
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());

users.put(user.getId(), user);
return user;
}

@Override
public void updateUser(Long id, UserUpdateRequest request) {
User user = getUserById(id);
user.setUsername(request.getUsername());
// Update other fields...
}

@Override
public void deleteUser(Long id) {
users.remove(id);
}

@Override
public List<User> searchUsers(String query) {
return users.values().stream()
.filter(u -> u.getUsername().contains(query))
.collect(Collectors.toList());
}

@Override
public boolean emailExists(String email) {
return users.values().stream()
.anyMatch(u -> u.getEmail().equals(email));
}
}

// Mobile app uses mock during development:
public class MobileApp {
private UserService userService = new MockUserService();

public void displayUserProfile(Long userId) {
User user = userService.getUserById(userId);
// Render UI with user data
}
}

Backend team implements real version:

package com.company.backend;

@Service
public class UserServiceImpl implements UserService {

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

@Override
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}

@Override
public User createUser(UserCreateRequest request) {
// Validate email doesn't exist
if (emailExists(request.getEmail())) {
throw new EmailAlreadyExistsException(request.getEmail());
}

// Create user entity
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));

return userRepository.save(user);
}

@Override
public void updateUser(Long id, UserUpdateRequest request) {
User user = getUserById(id);
user.setUsername(request.getUsername());
// Update other fields...
userRepository.save(user);
}

@Override
public void deleteUser(Long id) {
userRepository.deleteById(id);
}

@Override
public List<User> searchUsers(String query) {
return userRepository.findByUsernameContaining(query);
}

@Override
public boolean emailExists(String email) {
return userRepository.existsByEmail(email);
}
}

Mobile app switches to real implementation:

// Development: Mock
UserService userService = new MockUserService();

// Production: Real HTTP client
UserService userService = new HttpUserService("https://api.company.com");

// Mobile app code doesn't change!

Benefits:

  • ✅ Teams work independently
  • ✅ Mobile team doesn't wait for backend
  • ✅ Contract is clear and agreed upfront
  • ✅ Easy to test mobile app

Scenario 7: Testing Without Mocking Frameworks​

The Problem​

You want to test code that sends emails, but you don't want to:

  • Actually send emails during tests
  • Use a mocking framework (Mockito, etc.)

The Solution: Test Implementation​

public interface EmailService {
void sendEmail(String to, String subject, String body);
void sendEmailWithAttachment(String to, String subject, String body, File attachment);
}

// Production implementation
public class SmtpEmailService implements EmailService {

private String smtpHost;
private int smtpPort;

@Override
public void sendEmail(String to, String subject, String body) {
// Actually send email via SMTP
try {
Properties props = new Properties();
props.put("mail.smtp.host", smtpHost);
props.put("mail.smtp.port", smtpPort);

Session session = Session.getInstance(props);
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress("noreply@company.com"));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject);
message.setText(body);

Transport.send(message);
} catch (MessagingException e) {
throw new EmailException("Failed to send email", e);
}
}

// Other methods...
}

// Test implementation
public class MockEmailService implements EmailService {

private List<SentEmail> sentEmails = new ArrayList<>();

@Override
public void sendEmail(String to, String subject, String body) {
// Don't actually send - just record
sentEmails.add(new SentEmail(to, subject, body));
System.out.println("Would send email to: " + to);
System.out.println("Subject: " + subject);
}

// Verification methods for testing
public List<SentEmail> getSentEmails() {
return new ArrayList<>(sentEmails);
}

public boolean wasSentTo(String email) {
return sentEmails.stream().anyMatch(e -> e.getTo().equals(email));
}

public SentEmail getLastSentEmail() {
return sentEmails.isEmpty() ? null : sentEmails.get(sentEmails.size() - 1);
}

public void clear() {
sentEmails.clear();
}

public static class SentEmail {
private String to;
private String subject;
private String body;
private LocalDateTime sentAt;

// Constructor, getters
}

// Other methods...
}

Your service uses the interface:

public class UserService {

private EmailService emailService;
private UserRepository userRepository;

public UserService(EmailService emailService, UserRepository userRepository) {
this.emailService = emailService;
this.userRepository = userRepository;
}

public void registerUser(String email, String password) {
// Create user
User user = new User(email, password);
userRepository.save(user);

// Send welcome email
emailService.sendEmail(
email,
"Welcome to Our App!",
"Thanks for registering, " + email + "!"
);
}

public void sendPasswordReset(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException(email));

String resetToken = generateResetToken();
user.setResetToken(resetToken);
userRepository.save(user);

emailService.sendEmail(
email,
"Password Reset Request",
"Click here to reset: https://app.com/reset?token=" + resetToken
);
}
}

Testing is easy:

@Test
public void testUserRegistration() {
// Create mock email service
MockEmailService mockEmail = new MockEmailService();

// Create service with mock
UserService userService = new UserService(mockEmail, userRepository);

// Register user
userService.registerUser("john@email.com", "password123");

// Verify email was sent
assertTrue(mockEmail.wasSentTo("john@email.com"));

MockEmailService.SentEmail email = mockEmail.getLastSentEmail();
assertEquals("Welcome to Our App!", email.getSubject());
assertTrue(email.getBody().contains("Thanks for registering"));
}

@Test
public void testPasswordReset() {
MockEmailService mockEmail = new MockEmailService();
UserService userService = new UserService(mockEmail, userRepository);

// Request password reset
userService.sendPasswordReset("john@email.com");

// Verify reset email sent
assertTrue(mockEmail.wasSentTo("john@email.com"));

MockEmailService.SentEmail email = mockEmail.getLastSentEmail();
assertEquals("Password Reset Request", email.getSubject());
assertTrue(email.getBody().contains("reset?token="));
}

Production uses real implementation:

// Production configuration
EmailService emailService = new SmtpEmailService("smtp.gmail.com", 587);

// Testing
EmailService emailService = new MockEmailService();

// UserService works with both!
UserService userService = new UserService(emailService, userRepository);

Benefits:

  • ✅ Tests run fast (no actual emails)
  • ✅ No mocking framework needed
  • ✅ Can verify email content
  • ✅ Clear what was "sent" during tests

Summary: When to Use Interfaces​

The Pattern​

Interfaces are essential when you have:

1. Multiple ways to do something

Different payment processors
Different storage systems
Different sorting algorithms
Different notification methods

2. Code that shouldn't know details

Business logic doesn't care:
- If we use S3 or local storage
- If we send email via SMTP or SendGrid
- If we pay via Stripe or PayPal

3. Things that change

Plugins can be added
Teams work independently
Implementations can be swapped
Strategies can be switched at runtime

Frameworks vs Your Code​

You observed: "Frameworks and interfaces seem like siblings"

The truth:

Interfaces = Fundamental tool
Frameworks = Just really good at using that tool

Your code uses interfaces for:
- Plugin systems
- Team coordination
- Strategy patterns
- Event systems
- Storage abstraction
- Testing
- Runtime flexibility

Frameworks use interfaces for:
- All the same reasons!
- Plus: Automatic implementation generation (like Spring Data)

Real-World Frequency​

In a typical application:

Your interfaces (50%):
- Service contracts (UserService, OrderService)
- Storage abstractions (FileStorage, CacheProvider)
- Event listeners (EventHandler, ChangeListener)
- Plugin contracts (PaymentProcessor, NotificationSender)
- Strategy patterns (SortingStrategy, ValidationStrategy)

Framework interfaces (50%):
- Spring Data repositories (JpaRepository)
- Spring beans (@Component, @Service)
- Servlet interfaces (HttpServlet)

It's roughly 50/50 - not just frameworks!


The Big Insight​

Interfaces Solve Fundamental Problems​

Not just: "Tell coworkers to implement certain functions"

Actually: "Define contracts that enable:

  • Multiple implementations
  • Code decoupling
  • Runtime flexibility
  • Team collaboration
  • Easy testing
  • Plugin architectures
  • Strategy patterns
  • Event systems"

When You'll Use Them​

✅ Every time you have multiple ways to do something
✅ Every time different parts of your app need to coordinate
✅ Every time you want to test without real dependencies
✅ Every time you want to swap implementations
✅ Every time you build extensible systems

Not just:
❌ "Because frameworks do it"

Remember: Frameworks use interfaces because interfaces solve real problems. Those same problems exist in YOUR code too—plugin systems, team coordination, testing, flexibility. Interfaces aren't a "framework thing"—they're a fundamental software design tool! 🎯

Tags: #java #interfaces #design-patterns #plugin-architecture #strategy-pattern #observer-pattern #real-world