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