Working with Java is a canonical moment in the career of a backend engineer, eventually you will see yourself opening Intellij (or Eclipse) and facing the beauty of XML files Java classes. The last time I worked professionally with Java was when the classical version 8 was released. After that, multiple companies moved their backend development from C# and Java to Node.js, so did I. Although the environment was different, I always had the feeling that I was working in some sort of Springboot lite version. Check it out this example of an inversify controller, you will understand me.
@controller("/foo")
export class FooController implements Controller {
constructor(
@inject("FooService")
private fooService: FooService
) {}
@httpGet("/")
private index(
@request() req: Request,
@response() res: Response,
@next() next: NextFunction): string {
return this.fooService.get(req.query.id);
}
}
Now, new projects require me to code in Java again and I decided to write some articles to share my experience of (re)learning Java.
This post is about two features that really caught my attention when checking the updates of Java 17: sealed classes and pattern matching.
Java 17: Regaining Control with Sealed Classes
Java 17 introduced Sealed Classes, which let you explicitly restrict which other classes can extend or implement a particular class or interface. This gives you more control over class hierarchies, which can improve code clarity, maintainability, and safety. For example, you can define a PaymentMethod
and specify that it can only be extended/implemented by defined payment methods classes preventing other, unauthorized classes from inheriting from it. It becomes incredibly useful for modeling a finite set of possibilities in a domain.
// The sealed interface 'PaymentMethod' permits a finite set of implementations.
public sealed interface PaymentMethod permits CreditCardPayment, PayPalPayment, BankTransferPayment {}
// A final class for credit card payments.
public final class CreditCardPayment implements PaymentMethod {
private final String cardNumber;
private final String cardHolderName;
public CreditCardPayment(String cardNumber, String cardHolderName) {
this.cardNumber = cardNumber;
this.cardHolderName = cardHolderName;
}
public String getCardNumber() {
return cardNumber;
}
public String getCardHolderName() {
return cardHolderName;
}
}
// A final class for PayPal payments.
public final class PayPalPayment implements PaymentMethod {
private final String email;
public PayPalPayment(String email) {
this.email = email;
}
public String getEmail() {
return email;
}
}
// A final class for bank transfer payments.
public final class BankTransferPayment implements PaymentMethod {
private final String accountNumber;
private final String routingNumber;
public BankTransferPayment(String accountNumber, String routingNumber) {
this.accountNumber = accountNumber;
this.routingNumber = routingNumber;
}
public String getAccountNumber() {
return accountNumber;
}
public String getRoutingNumber() {
return routingNumber;
}
}
This setup guarantees that any PaymentMethod
object you work with must be one of CreditCardPayment
, PayPalPayment
, or BankTransferPayment
. If a developer tries to create a new class that implements PaymentMethod
but isn't one of the three permitted classes, the compiler will produce an error. While this might seem unnecessary in a small example, the ability to restrict who can implement or extend a class is incredibly useful for controlling and managing how your codebase grows.
Java 17: Pattern Matching for Switch
In Java 8, if you implemented a strategy pattern once you probably saw a code like this one:
public class PaymentProcessor {
public void process(PaymentMethod paymentMethod) {
if (payment instanceof CreditCardPayment) {
System.out.println("Processing credit card payment for " + creditCard.getCardHolderName());
// Logic for processing a credit card
} else if (paymentMethod instanceof PayPalPayment) {
System.out.println("Processing PayPal payment for " + payPal.getEmail());
// Logic for processing a PayPal transaction
} else if (paymentMethod instanceof BankTransferPayment) {
System.out.println("Processing bank transfer to account " + bankTransfer.getAccountNumber());
// Logic for handling a bank transfer
} else {
// In this case we need to add a "default" handler as
// the compiler cannot know all the possible methods
throw new IllegalArgumentException("Unknown shape type");
}
}
}
Version 17 adds a Pattern Matching
for switch
, which allows patterns to be used in case
labels, making code more concise and readable. The feature eliminates the need for explicit type casting after an instanceof
check and can be used to handle different types of objects in a single switch
statement.
The sealed interface is extremely powerful when combined with pattern matching for switch. You can write clean, concise logic to process each payment type without the boilerplate of an if-else
chain.
public class PaymentProcessor {
public void process(PaymentMethod paymentMethod) {
switch (paymentMethod) {
case CreditCardPayment creditCard -> {
System.out.println("Processing credit card payment for " + creditCard.getCardHolderName());
// Logic for processing a credit card
}
case PayPalPayment payPal -> {
System.out.println("Processing PayPal payment for " + payPal.getEmail());
// Logic for processing a PayPal transaction
}
case BankTransferPayment bankTransfer -> {
System.out.println("Processing bank transfer to account " + bankTransfer.getAccountNumber());
// Logic for handling a bank transfer
}
// No 'default' case is needed because the compiler knows all
// possible subclasses of 'PaymentMethod' are handled.
}
}
}
Because the PaymentMethod
interface is sealed, the Java compiler can analyze the switch
statement and verify that all possible PaymentMethod
types are covered. This provides compile-time safety, preventing bugs that could occur if a new, unhandled payment type were introduced. The compiler essentially guarantees that your code handles every valid scenario.
Top comments (0)