Programming languages offer many useful features that make our lives easier. Some of them may seem like tools for better code organization, but it’s worth remembering that they can also shape the way we design software — like generics in Java.
Since you’re reading this article online, you can easily find many Java resources explaining generics. Here, however, we’ll focus on how to use this feature as a practical design tool through real-world use cases.
Let's get started!
Example:
I tried to find a really bad example that you can see almost every day in a legacy project. :)
We have a platform that allows our clients to pay using credit cards or stored card tokens. Each payment method is handled by a different payment provider.
public class CardPaymentStrategyImpl implements CardPaymentStrategy {
private final BobPayImpl client;
public CardPaymentStrategyImpl(BobPayImpl client) {
this.client = client;
}
@Override
public TransactionResult authorize(CardPaymentDetails paymentDetails, BigDecimal amount) {
BobPayCardPaymentAuthorizationResponse cardPaymentResult = client.authorizeCardPayment(paymentDetails, amount);
// TODO: Map cardPaymentResult to TransactionResult
return new TransactionResult();
}
}
public class TokenPaymentStrategyImpl implements TokenPaymentStrategy {
private final AlicePayImpl client;
public TokenPaymentStrategyImpl(AlicePayImpl client) {
this.client = client;
}
@Override
public TransactionResult authorize(TokenPaymentDetails paymentDetails, BigDecimal amount) {
AlicePayTokenPaymentAuthorizationResponse tokenPaymentResult = client.authorizeTokenPayment(paymentDetails, amount);
// TODO: Map tokenPaymentResult to TransactionResult
return new TransactionResult();
}
}
public class PaymentProcessor {
public TransactionResult authorize(CardPaymentStrategy strategy, CardPaymentDetails paymentDetails, BigDecimal amount) {
return strategy.authorize(paymentDetails, amount);
}
public TransactionResult authorize(TokenPaymentStrategy strategy, TokenPaymentDetails paymentDetails, BigDecimal amount) {
return strategy.authorize(paymentDetails, amount);
}
}
As you can see, we have two low-level client libraries that provide payment methods with different method names and response models. The classes implementing CardPaymentStrategy
and TokenPaymentStrategy
help us standardize the types and method names by encapsulating the low-level clients. This allows the PaymentProcessor class to interact with the strategy classes using internal types. The solution works, but let’s take it a step further and improve it using the tools we have.
At first glance, the PaymentProcessor
class doesn’t really care which strategy you call and takes advantage of Java’s method overloading. However, there are some code smells coming from this class. Ideally, it should be payment-method agnostic — we shouldn’t need to add another method every time we implement a new payment method.
Let's start by replacing the CardPaymentStrategy
and TokenPaymentStrategy
interfaces with PaymentStrategy<T>
generic interface.
public interface PaymentStrategy<T> {
TransactionResult authorize(T paymentDetails, BigDecimal amount);
}
public class CardPaymentStrategyImpl implements PaymentStrategy<CardPaymentDetails> {
//...
}
public class TokenPaymentStrategyImpl implements PaymentStrategy<TokenPaymentDetails> {
//...
}
And finally, our PaymentProcessor class would look like this:
public class PaymentProcessor {
public <T> TransactionResult authorize(PaymentStrategy<T> strategy, T paymentDetails, BigDecimal amount) {
return strategy.authorize(paymentDetails, amount);
}
}
Generics are one of the most powerful features in Java. They help us write cleaner, safer, and more reusable code — all while catching type errors at compile time instead of at runtime.
But here’s something that often surprises developers: generics don’t actually exist at runtime in Java. So how does that work?
Let’s talk about Type Erasure.
Type erasure is the process by which the Java compiler (javac) removes all generic type information (such as or ) during compilation.
At compile time, the compiler uses generic type parameters (like T) to enforce type safety. For example, it ensures that your CardPaymentStrategyImpl can only receive a CardDetails object and reports an error if you attempt to pass a different type.
At runtime, however, all generic type information is erased and replaced with their unbounded type (usually Object) or with their upper bound if one was specified.
In the context of your PaymentStrategy<T>
interface, the runtime sees the following:
Code Segment | Compile-Time View | Runtime View |
---|---|---|
Interface | PaymentStrategy<T> |
PaymentStrategy (Raw Type) |
Method | TransactionResult authorize(T details, ...) |
TransactionResult authorize(Object details, ...) |
Strategy | CardPaymentStrategyImpl implements PaymentStrategy<CardDetails> |
CardPaymentStrategyImpl implements PaymentStrategy |
For more information, I would strongly recommend checking the resources specified in the Credits section.
I tried to keep it as brief as possible, but there are many real-world cases in the libraries we use every day — for example, HTTP client libraries that map objects to requests or responses to objects.
Thanks for reading!
Credits:
- Cover photo by Sigmund on Unsplash
- Type Erasure in Java Explained
Top comments (0)