Skip to main content

Utility Classes vs Interfaces: Why Static Methods Aren't Enough

Β· 8 min read
Mahmut Salman
Software Developer

"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​

AspectUtility ClassInterface + Spring
Add sorting logicModify SortUtils.javaCreate TrendingSort.java
Update ProductListViewAdd if-else caseNo change needed
Update RecommendationEngineAdd if-else caseNo change needed
Update AdminPanelAdd if-else caseNo change needed
Update ReportGeneratorAdd if-else caseNo change needed
Files changed5 files1 file
Risk of breaking existing codeHigh (4 places to update)Low (isolated change)

Scenario: Unit Testing​

AspectUtility ClassInterface
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​

AspectUtility ClassInterface
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​

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!

ProblemUtility ClassInterface
Duplicate logicβœ… Solvedβœ… Solved
Scattered if-else❌ Still existsβœ… Solved
Add new featureModify 5 filesAdd 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:

  1. Eliminating scattered if-else completely (not just centralizing logic)
  2. Enabling polymorphism (treating different behaviors uniformly)
  3. Supporting dependency injection (testability and flexibility)
  4. 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! 🎯