Utility Classes vs Interfaces: Why Static Methods Aren't Enough
"Can't we just create a utility class with static methods instead of using interfaces? Isn't the interface approach just fancy?" Great question! Yes, you could use utility classes - but you'd be solving only 50% of the problem. Let's see what happens in practice and why interfaces aren't fancy, they're necessary.
The Utility Class Approachβ
Your Idea: Static Methodsβ
public class SortUtils {
public static List<Product> sortByPriceLowToHigh(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice))
.collect(Collectors.toList());
}
public static List<Product> sortByPriceHighToLow(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice).reversed())
.collect(Collectors.toList());
}
public static List<Product> sortByRating(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingDouble(Product::getRating).reversed())
.collect(Collectors.toList());
}
}
Looks clean, right? The sorting logic is centralized in one place.
The Usageβ
public class ProductListView {
public void displayProducts(String sortType) {
List<Product> sorted;
// β οΈ Still need if-else to pick the method!
if (sortType.equals("price_asc")) {
sorted = SortUtils.sortByPriceLowToHigh(products);
} else if (sortType.equals("price_desc")) {
sorted = SortUtils.sortByPriceHighToLow(products);
} else if (sortType.equals("rating")) {
sorted = SortUtils.sortByRating(products);
} else {
sorted = products;
}
render(sorted);
}
}
Wait... You still have if-else chains!
The 5 Problems with Utility Classesβ
Problem 1: Scattered If-Else STILL Existsβ
You've centralized the sorting logic but NOT the selection logic.
Every place that needs sorting still has the same if-else block:
// ProductListView.java - if-else here β
if (sortType.equals("price_asc")) {
sorted = SortUtils.sortByPriceLowToHigh(products);
} else if (sortType.equals("price_desc")) {
sorted = SortUtils.sortByPriceHighToLow(products);
}
// RecommendationEngine.java - if-else here β
if (sortType.equals("price_asc")) {
sorted = SortUtils.sortByPriceLowToHigh(products);
} else if (sortType.equals("price_desc")) {
sorted = SortUtils.sortByPriceHighToLow(products);
}
// AdminPanel.java - if-else here β
if (sortType.equals("price_asc")) {
sorted = SortUtils.sortByPriceLowToHigh(products);
} else if (sortType.equals("price_desc")) {
sorted = SortUtils.sortByPriceHighToLow(products);
}
// ReportGenerator.java - if-else here β
if (sortType.equals("price_asc")) {
sorted = SortUtils.sortByPriceLowToHigh(products);
} else if (sortType.equals("price_desc")) {
sorted = SortUtils.sortByPriceHighToLow(products);
}
You've only solved half the problem! The logic is centralized, but the selection is still scattered.
Problem 2: Can't Pass Behavior as a Parameterβ
With utility methods - IMPOSSIBLE:
// β Can't do this with static methods
public void displayProducts(??? sortingBehavior) {
List<Product> sorted = sortingBehavior.sort(products);
render(sorted);
}
What type would sortingBehavior be? You can't pass static methods around.
With interfaces - EASY:
// β
Pass behavior like data!
public void displayProducts(SortingStrategy strategy) {
List<Product> sorted = strategy.sort(products);
render(sorted);
}
// Use it:
displayProducts(new PriceSort());
displayProducts(new RatingSort());
displayProducts(new TrendingSort());
This is polymorphism - treating different implementations uniformly.
Problem 3: Can't Store in Collectionsβ
With utility class - IMPOSSIBLE:
// β What type? You can't store methods!
Map<String, ???> sortMethods = new HashMap<>();
sortMethods.put("price_asc", SortUtils.sortByPriceLowToHigh); // ERROR!
You can't store method references in a map directly with this approach.
With interfaces - EASY:
// β
Store implementations in a map
Map<String, SortingStrategy> strategies = new HashMap<>();
strategies.put("price_asc", new PriceLowToHighSort());
strategies.put("price_desc", new PriceHighToLowSort());
strategies.put("rating", new RatingSort());
// Use it:
SortingStrategy strategy = strategies.get(sortType);
List<Product> sorted = strategy.sort(products);
This is the registry pattern - impossible with utility classes!
Problem 4: Hard to Extend at Runtimeβ
Adding "Trending" sort:
With utility class:
// Step 1: Modify SortUtils.java
public static List<Product> sortByTrending(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingInt(Product::getViewCount).reversed())
.collect(Collectors.toList());
}
// Step 2: Update ProductListView if-else
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// Step 3: Update RecommendationEngine if-else
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// Step 4: Update AdminPanel if-else
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// Step 5: Update ReportGenerator if-else
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
Files changed: 5 (SortUtils + 4 calling files)
With interfaces + Spring:
// Just create new class - that's it!
@Component
public class TrendingSort implements SortingStrategy {
@Override
public String getName() {
return "trending";
}
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingInt(Product::getViewCount).reversed())
.collect(Collectors.toList());
}
}
Files changed: 1 (just the new class)
No other files need to change! Spring auto-discovers and registers it.
Problem 5: Can't Use Dependency Injectionβ
With utility class:
// β Tightly coupled to static method
@Service
public class ProductService {
public void process(String sortType) {
if (sortType.equals("price_asc")) {
List<Product> sorted = SortUtils.sortByPriceLowToHigh(products);
}
// Hard to mock in tests!
// Hard to swap implementations!
}
}
With interfaces:
// β
Loosely coupled, easily testable
@Service
public class ProductService {
@Autowired
private SortingStrategy strategy; // Can inject different implementations!
public void process() {
List<Product> sorted = strategy.sort(products);
// Easy to mock in tests!
// Easy to swap implementations!
}
}
// In tests:
@Test
public void testProcess() {
SortingStrategy mockStrategy = mock(SortingStrategy.class);
ProductService service = new ProductService(mockStrategy);
// Test with mock!
}
The Real Power: Polymorphismβ
With interfaces, you can treat different implementations uniformly:
public class GenericProductView {
private final SortingStrategy strategy;
// Accept ANY sorting strategy!
public GenericProductView(SortingStrategy strategy) {
this.strategy = strategy;
}
public void display() {
// Works with ANY implementation!
List<Product> sorted = strategy.sort(products);
render(sorted);
}
}
// Create different views with different behaviors
GenericProductView priceView = new GenericProductView(new PriceSort());
GenericProductView ratingView = new GenericProductView(new RatingSort());
GenericProductView trendingView = new GenericProductView(new TrendingSort());
Try doing that with static utility methods! You can't.
Side-by-Side Comparisonβ
Scenario: Add "Trending" Sortβ
| Aspect | Utility Class | Interface + Spring |
|---|---|---|
| Add sorting logic | Modify SortUtils.java | Create TrendingSort.java |
| Update ProductListView | Add if-else case | No change needed |
| Update RecommendationEngine | Add if-else case | No change needed |
| Update AdminPanel | Add if-else case | No change needed |
| Update ReportGenerator | Add if-else case | No change needed |
| Files changed | 5 files | 1 file |
| Risk of breaking existing code | High (4 places to update) | Low (isolated change) |
Scenario: Unit Testingβ
| Aspect | Utility Class | Interface |
|---|---|---|
| Mock sorting behavior | β Can't mock static methods easily | β Easy to mock |
| Test with different strategies | β Hardcoded calls | β Inject mock implementations |
| Test without database | β Static method might hit DB | β Mock returns fake data |
Scenario: Runtime Configurationβ
| Aspect | Utility Class | Interface |
|---|---|---|
| Store strategies in map | β Can't store methods | β Easy with registry |
| Pass as parameter | β No type to pass | β
Pass as SortingStrategy |
| Switch at runtime | β Need if-else chains | β Look up in registry |
| Add new strategy at runtime | β Code change required | β Just add @Component class |
When Utility Classes ARE Appropriateβ
Utility classes are good for pure functions with no variation:
public class StringUtils {
// Only ONE way to check if string is empty
public static boolean isEmpty(String s) {
return s == null || s.trim().isEmpty();
}
// Only ONE way to capitalize
public static String capitalize(String s) {
if (isEmpty(s)) return s;
return s.substring(0, 1).toUpperCase() + s.substring(1);
}
// Only ONE way to reverse
public static String reverse(String s) {
return new StringBuilder(s).reverse().toString();
}
}
Why utility class works here:
- These are pure functions (same input β same output)
- No multiple implementations (only ONE way to check if string is empty)
- No state (stateless operations)
- No need for polymorphism (you always want the same behavior)
But for sorting, you have MANY strategies - that's where interfaces shine!
The Real Comparisonβ
What Utility Classes Solveβ
Before:
ββββββββββββββββββ
β ProductListView β β Duplicate sorting logic
ββββββββββββββββββ
ββββββββββββββββββββββ
β RecommendationEngineβ β Duplicate sorting logic
ββββββββββββββββββββββ
ββββββββββββββββββ
β AdminPanel β β Duplicate sorting logic
ββββββββββββββββββ
After (Utility Class):
ββββββββββββββββββ
β ProductListView β β if-else to choose SortUtils method
ββββββββββββββββββ
ββββββββββββββββββββββ
β RecommendationEngineβ β if-else to choose SortUtils method
ββββββββββββββββββββββ
ββββββββββββββββββ
β AdminPanel β β if-else to choose SortUtils method
ββββββββββββββββββ
ββββββββββββββββββ
β SortUtils β β Centralized sorting logic β
ββββββββββββββββββ
Result: Centralized logic β , but scattered selection β
What Interfaces Solveβ
After (Interface + Registry):
ββββββββββββββββββ
β ProductListView β β strategy.sort(products) β
ββββββββββββββββββ
ββββββββββββββββββββββ
β RecommendationEngineβ β strategy.sort(products) β
ββββββββββββββββββββββ
ββββββββββββββββββ
β AdminPanel β β strategy.sort(products) β
ββββββββββββββββββ
ββββββββββββββββββββββ
β SortingRegistry β β Map lookup β
ββββββββββββββββββββββ
ββββββββββββββββββ
β PriceSort β β Implementation
ββββββββββββββββββ
ββββββββββββββββββ
β RatingSort β β Implementation
ββββββββββββββββββ
ββββββββββββββββββ
β TrendingSort β β Implementation
ββββββββββββββββββ
Result: Centralized logic β AND centralized selection β
The Bottom Lineβ
Utility Class Approachβ
What you get:
- β Centralized sorting logic
- β Scattered if-else selection logic
- β Can't pass behavior as parameters
- β Can't store in collections
- β Can't use dependency injection
- β Hard to extend without modifying multiple files
Conclusion: You're only 50% of the way there!
Interface Approachβ
What you get:
- β Centralized sorting logic
- β Centralized selection logic (registry)
- β Pass behavior as parameters (polymorphism)
- β Store in collections (registry pattern)
- β Use dependency injection (testable)
- β Extend by adding files, not modifying them (Open/Closed Principle)
Conclusion: Complete solution!
Code Burden Comparisonβ
Adding New "Trending" Sortβ
Utility Class:
// 1. SortUtils.java - add method (20 lines)
public static List<Product> sortByTrending(List<Product> products) {
// implementation
}
// 2. ProductListView.java - add if-else (5 lines)
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// 3. RecommendationEngine.java - add if-else (5 lines)
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// 4. AdminPanel.java - add if-else (5 lines)
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// 5. ReportGenerator.java - add if-else (5 lines)
} else if (sortType.equals("trending")) {
sorted = SortUtils.sortByTrending(products);
}
// Total: 5 files, 40 lines of changes
Interface + Spring:
@Component
public class TrendingSort implements SortingStrategy {
@Override
public String getName() {
return "trending";
}
@Override
public List<Product> sort(List<Product> products) {
return products.stream()
.sorted(Comparator.comparingInt(Product::getViewCount).reversed())
.collect(Collectors.toList());
}
}
// Total: 1 file, 15 lines
Summaryβ
The Questionβ
"Can't we just use a utility class with static methods? Isn't the interface approach just fancy?"
The Answerβ
No, it's not fancy - it's necessary!
| Problem | Utility Class | Interface |
|---|---|---|
| Duplicate logic | β Solved | β Solved |
| Scattered if-else | β Still exists | β Solved |
| Add new feature | Modify 5 files | Add 1 file |
| Pass behavior | β Can't | β Can |
| Store in map | β Can't | β Can |
| Dependency injection | β Can't | β Can |
| Unit testing | β Hard | β Easy |
Utility classes solve 50% of the problem. Interfaces solve 100%.
The interface approach isn't about being fancy - it's about:
- Eliminating scattered if-else completely (not just centralizing logic)
- Enabling polymorphism (treating different behaviors uniformly)
- Supporting dependency injection (testability and flexibility)
- Following Open/Closed Principle (extend by adding, not modifying)
Use utility classes for pure functions with no variation. Use interfaces for behaviors with multiple strategies.
Now you understand why interfaces aren't fancy - they're the right tool for the job! π―
