DEV Community

Claudio Busatto
Claudio Busatto

Posted on

I'm back to Java, and this is what I found (so far)

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)