Sealed classes, introduced in Java 15 as a preview feature and later refined in subsequent releases, represent a middle ground between the two extremes of the class world: the completely open public
class, and the completely closed final
class.
A sealed class restricts which other classes or interfaces can extend or implement it. It’s defined using the sealed
, non-sealed
, or permits
modifier.
public sealed class Shape permits Circle, Rectangle, Triangle { }
In this example, Shape
is a sealed class, and only Circle
, Rectangle
, and Triangle
can extend it.
public sealed class Animal permits Dog, Cat { } public final class Dog extends Animal { } public final class Cat extends Animal { }
Note: Classes extending a sealed class can be final
, sealed
, or non-sealed
.
Sealed classes are a powerful addition to the Java language, offering a level of control over inheritance that wasn’t previously available. Like all features, they have their pros and cons, and the decision to use them should be based on the specific needs of the project and the problems you’re trying to solve.
In Java, it’s common to have classes that exist solely to carry data, i.e., classes with fields, simple methods like getters, equals()
, hashCode()
, and toString()
. Before records, defining such classes could be quite verbose.
A record is a restricted form of class that provides a concise way to define such classes. When you define a record, the Java compiler automatically implements several standard methods for you.
final
and their fields are also final
. Thus, once a record is created, you cannot modify its state.equals()
, hashCode()
, and toString()
based on the fields of the record.public record Point(int x, int y) { }
This simple declaration creates:
x
and y
.x()
and y()
).equals()
method that checks for equality based on the values of x
and y
.hashCode()
method based on the values of x
and y
.toString()
method that returns a string in the format Point[x=<value>, y=<value>]
.public class TestRecords { public static void main(String[] args) { Point p1 = new Point(5, 10); Point p2 = new Point(5, 10); System.out.println(p1); // Prints: Point[x=5, y=10] System.out.println(p1.equals(p2)); // Prints: true } }
While records are meant to be simple data carriers, you can still customize them:
You can define custom constructors, but they should call the canonical constructor:
public record Rectangle(int width, int height) { public Rectangle { if (width < 0 || height < 0) { throw new IllegalArgumentException("Dimensions should be positive"); } } }
You can add methods to a record:
public record Point(int x, int y) { public double distanceFromOrigin() { return Math.sqrt(x * x + y * y); } }
Records can implement interfaces:
public record NamedPoint(int x, int y, String name) implements Comparable<NamedPoint> { @Override public int compareTo(NamedPoint other) { return name.compareTo(other.name); } }
Things to Remember:
Record components are final by default.
Records can’t be abstract.
Records can’t extend other classes.
You can’t declare instance fields in a record that aren’t record components.
Records are a significant step toward making Java more expressive and less verbose, especially for common coding patterns.
Caveats and Considerations for using Record with JPA:
Annotations: With records, you can’t annotate fields directly since they’re declared in the record header. If you need to add JPA annotations (like @Id, @GeneratedValue, etc.), they should be added to the accessor methods (getters) of the record.
Immutability: Records are inherently immutable, which means all their fields are final. This behavior aligns with the principle of immutability in entity design but can complicate scenarios where you want to change an entity’s state after it has been constructed.
No–Args Constructor: JPA typically requires a no–args constructor, which records don’t provide out of the box. Hibernate’s (a popular JPA implementation) team has been working to support records, so expect improvements in this area. If your JPA provider doesn’t support records yet, consider using DTOs (Data Transfer Objects) alongside your entities.
Experimental Support: As mentioned earlier, while Spring Data started introducing experimental support for records, this might not be fully mature or cover all scenarios. Always refer to the latest Spring Data documentation and check for compatibility and best practices.
Java method references are a shorthand notation of a lambda expression to call a method. They became available in Java 8, just like lambdas and streams. Method references allow for a cleaner and more readable way to refer to methods without executing them.
Static Method Reference
a reference to a static method is a type of method reference that allows you to reference and use a static method of a class as a functional interface. It is denoted by ClassName::staticMethodName. This type of method reference is useful when you want to pass a static method as an argument to a functional interface or use it in lambda expressions.
ClassName::staticMethodName
static void staticMethodReference() { System.out.println("\nstaticMethodReference..."); // Using lambda expression Arrays.asList("apple", "banana", "orange").forEach(str -> System.out.println(str.toUpperCase())); // Using method reference Arrays.asList("apple", "banana", "orange").forEach(Java8MethodReference::convertToUpperCase); // using static method to print out users.forEach(System.out::println); System.out.println("staticMethodReference done!"); } public static void convertToUpperCase(String str) { System.out.println(str.toUpperCase()); }
Instance Method Reference
a reference to an instance method is a type of method reference that allows you to reference and use an instance method of a particular object as a functional interface. It is denoted by instance::instanceMethodName. This type of method reference is useful when you want to pass an instance method as an argument to a functional interface or use it in lambda expressions.
instance::instanceMethodName
static void instanceMethodReference() { System.out.println("\ninstanceMethodReference..."); List<String> fruits = Arrays.asList("apple", "banana", "orange"); // Using lambda expression fruits.forEach(str -> { System.out.println("str: " + str + ",length: " + str.length()); }); Java8MethodReference java8MethodReference = new Java8MethodReference(); // Using method reference fruits.forEach(java8MethodReference::printLength); System.out.println("instanceMethodReference done!"); } public void printLength(String str) { System.out.println("str: " + str + ",length: " + str.length()); }
Constructor Method Reference
a reference to a constructor is a type of method reference that allows you to reference and use a constructor of a class as a functional interface. It is denoted by ClassName::new. This type of method reference is useful when you want to pass a constructor as an argument to a functional interface or use it in lambda expressions.
ClassName::new
static void constructorMethodReference() { System.out.println("\nconstructorMethodReference..."); // Using lambda expression Supplier<User> personLambda = () -> User.builder().firstName("John").lastName("Doe").build(); User johnFromLambda = personLambda.get(); System.out.println("User from lambda: " + johnFromLambda); // Using method reference Supplier<User> personMethodRef = User::new; User johnFromMethodRef = personMethodRef.get(); System.out.println("User from method reference: " + johnFromMethodRef); System.out.println("constructorMethodReference done!"); }
Java Optional is a class that represents a value that may or may not be present. It is used to avoid NullPointerExceptions when dealing with nullable values.
Optional
is a class introduced to handle the absence of a value in a more elegant and safe way. It is designed to avoid null pointer exceptions and make it clear when a value may be absent. An Optional
object can either contain a non-null value or represent that no value is present.
Before Java 8, when a method could return a null
value, developers had to explicitly check for null before performing any operations on the returned value. This often led to cluttered and error-prone code. With Optional
, you can avoid explicit null checks and handle the absence of values in a more functional and streamlined manner.
The Optional
class provides several methods to interact with and manipulate the contained value, such as get()
, isPresent()
, orElse()
, orElseGet()
, orElseThrow()
, and more.
Here’s a simple example to demonstrate the use of Optional
:
import java.util.Optional; public class OptionalExample { public static void main(String[] args) { String name = "John"; // Change this to null to see the difference // Creating an Optional object from the value Optional<String> optionalName = Optional.ofNullable(name); // Checking if a value is present if (optionalName.isPresent()) { System.out.println("Name: " + optionalName.get()); } else { System.out.println("Name is not present."); } // Using orElse to provide a default value if the value is absent String defaultName = optionalName.orElse("Unknown"); System.out.println("Default Name: " + defaultName); // Using orElseGet to provide a default value using a supplier if the value is absent String otherDefaultName = optionalName.orElseGet(() -> "Anonymous"); System.out.println("Other Default Name: " + otherDefaultName); } }
Output when name = "John"
:
Name: John Default Name: John Other Default Name: John
Output when name = null
:
Name is not present. Default Name: Unknown Other Default Name: Anonymous
In this example, we create an Optional
object using the static ofNullable()
method. If the value is present, we can retrieve it using the get()
method. However, it is advisable to use the isPresent()
method before calling get()
to avoid a NoSuchElementException
. Alternatively, you can use the orElse()
method to provide a default value if the Optional
object is empty.
The Optional
class encourages better code design by making it explicit when a value may be absent and providing clear methods for handling both present and absent cases.
It’s essential to use Optional
wisely and not overuse it. It is best suited for method return types and fields that might be absent. Avoid using Optional
as a replacement for traditional null checks within method bodies.
Here are some of the benefits of using Java Optional:
isPresent()
The Optional class provides the isPresent() method, which is used to check whether an Optional object contains a non–null value or is empty (contains a null value). The isPresent() method returns a boolean value indicating whether the Optional object holds a value.
static void isPresent() { System.out.println("isPresent..."); String firstName = faker.name().firstName(); String lastName = faker.name().lastName(); String email = (firstName + lastName).toLowerCase() + "@gmail.com"; User user = User.builder().firstName(firstName).lastName(lastName).email(email).phoneNumber(faker.phoneNumber().cellPhone()).build(); System.out.println("User: " + user.toString()); Optional<User> optUser = Optional.ofNullable(user); // Checking if the Optional object contains a value if (optUser.isPresent()) { System.out.println("User is present: " + optUser.get()); } else { System.out.println("User is not present."); } System.out.println("isPresent done!"); }
The output
isPresent... User: User(id=0, firstName=Natalie, lastName=Braun, email=nataliebraun@gmail.com, phoneNumber=975.490.2241) User is present: User(id=0, firstName=Natalie, lastName=Braun, email=nataliebraun@gmail.com, phoneNumber=975.490.2241) isPresent done!
ifPresentOrElse()
The Optional
class introduced the ifPresentOrElse()
method. This method provides a concise way to perform an action if the Optional
object contains a value, and an alternative action if the Optional
object is empty.
static void ifPresentOrElse() { System.out.println("ifPresentOrElse..."); String firstName = faker.name().firstName(); String lastName = faker.name().lastName(); String email = (firstName + lastName).toLowerCase() + "@gmail.com"; User user = User.builder().firstName(firstName).lastName(lastName).email(email).phoneNumber(faker.phoneNumber().cellPhone()).build(); System.out.println("User: " + user.toString()); Optional<User> optUser = Optional.ofNullable(user); optUser.ifPresentOrElse(u -> { System.out.println("user is present"); System.out.println("User: " + u.toString()); }, () -> { System.out.println("else"); }); user = null; optUser = Optional.ofNullable(user); optUser.ifPresentOrElse(u -> { System.out.println("user is present"); }, () -> { System.out.println("user is null"); }); System.out.println("ifPresentOrElse done!"); }
ofElse()
static void orElse() { System.out.println("orElse..."); String firstName = faker.name().firstName(); String lastName = faker.name().lastName(); String email = (firstName + lastName).toLowerCase() + "@gmail.com"; User user = User.builder().firstName(firstName).lastName(lastName).email(email).phoneNumber(faker.phoneNumber().cellPhone()).build(); System.out.println("User: " + user.toString()); Optional<User> optUser = Optional.ofNullable(user); User u = optUser.orElse(User.builder().firstName("Test").build()); System.out.println("User firstName: " + u.getFirstName()); user = null; optUser = Optional.ofNullable(user); u = optUser.orElse(User.builder().firstName("Test").build()); System.out.println("User firstName: " + u.getFirstName()); System.out.println("orElse done!"); }
filter()
The Optional class provides the filter() method, which is used to conditionally process the value contained within the Optional. The filter() method checks if the Optional contains a value that matches a given predicate (a condition), and if so, it returns the same Optional object. If the Optional is empty (contains a null value) or the predicate evaluates to false for the value, the filter() method returns an empty Optional.
static void filter() { System.out.println("filter..."); String firstName = faker.name().firstName(); String lastName = faker.name().lastName(); String email = (firstName + lastName).toLowerCase() + "@gmail.com"; User user = User.builder().firstName(firstName).lastName(lastName).email(email).phoneNumber(faker.phoneNumber().cellPhone()).build(); System.out.println("User: " + user.toString()); user = null; Optional<User> optUser = Optional.ofNullable(user); optUser = optUser.filter(u -> { System.out.println("running filter if value is not null"); return u.getFirstName().length() > 0; }); optUser.ifPresent(u -> { System.out.println("User firstName: " + u.getFirstName()); }); System.out.println("filter done!"); }
map()
static void map() { System.out.println("map..."); String firstName = faker.name().firstName(); String lastName = faker.name().lastName(); String email = (firstName + lastName).toLowerCase() + "@gmail.com"; User user = User.builder().firstName(firstName).lastName(lastName).email(email).phoneNumber(faker.phoneNumber().cellPhone()).build(); System.out.println("User: " + user.toString()); user = null; Optional<User> optUser = Optional.ofNullable(user); Optional<String> optEmail = optUser.map(u -> { System.out.println("running map if value is not null"); return u.getEmail(); }); optEmail.ifPresent(em -> { System.out.println("User email: " + em); }); System.out.println("map done!"); }
CompletableFuture was introduced in Java 8 and is part of the java.util.concurrent package. It represents a future result of an asynchronous computation—a way to write non–blocking asynchronous code in Java. The main advantage of using CompletableFuture is its ability to chain multiple asynchronous operations, handle exceptions, combine results, and apply transformations without blocking the execution thread.
CompletableFuture.supplyAsync()
The CompletableFuture.supplyAsync() method is a handy tool in Java’s arsenal for asynchronous programming. It provides a way to execute asynchronous computations that produce a result. This method is used to initiate an asynchronous computation and return a CompletableFuture that will be completed with the result of that computation. The computation itself is specified as a Supplier<T>, where T is the type of the resulting value.
static void runSupplyAsync() { System.out.println("runSupplyAsync..."); CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // Simulate some long-running task try { System.out.println("supplyAsync sleeping..."); Thread.sleep(1500); System.out.println("supplyAsync done sleeping!"); } catch (InterruptedException e) { throw new IllegalStateException(e); } return "Result"; }); // This will print immediately System.out.println("Waiting for the result..."); String result = future.join(); System.out.println("return result: " + result); System.out.println("runSupplyAsync done!"); }
Output
runSupplyAsync... Waiting for the result... supplyAsync sleeping... supplyAsync done sleeping! return result: Result runSupplyAsync done!
static void runSupplyAsyncThen() { System.out.println("runSupplyAsyncThen..."); CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // Simulate some long-running task try { System.out.println("supplyAsync sleeping..."); Thread.sleep(1500); System.out.println("supplyAsync done sleeping!"); } catch (InterruptedException e) { throw new IllegalStateException(e); } return "Result"; }).thenApply(result -> { System.out.println("thenApply, result=" + result); return result + result; }).thenApply(result -> { System.out.println("thenApply, result=" + result); return result + result + result; }); future.thenAccept(result -> { System.out.println("thenAccept, result=" + result); }); // This will print immediately System.out.println("Waiting for the result..."); String result = future.join(); System.out.println("return result: " + result); System.out.println("runSupplyAsyncThen done!"); }
Completable.runAsync()
While CompletableFuture.supplyAsync() is designed to initiate asynchronous computations that produce a result, there are times when you want to perform an action that doesn’t return anything. That’s where CompletableFuture.runAsync() comes in.
This method is used to run a Runnable task asynchronously. A Runnable doesn’t return a result. Hence, the CompletableFuture returned by runAsync() is of type Void.
static void runAsync() { System.out.println("runAsync..."); CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { // Simulate some long-running task try { System.out.println("runAsync sleeping..."); Thread.sleep(1500); System.out.println("runAsync done sleeping!"); } catch (InterruptedException e) { throw new IllegalStateException(e); } }); // This will block until the future is completed (i.e., the Runnable has executed) future.join(); System.out.println("runAsync done!"); }
static void runWithCustomExecutor() { System.out.println("runWithCustomExecutor..."); Executor executor = Executors.newFixedThreadPool(2); CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { // Simulate some long-running task try { System.out.println("runAsync sleeping..."); Thread.sleep(1500); System.out.println("runAsync done sleeping!"); } catch (InterruptedException e) { throw new IllegalStateException(e); } }, executor); // This will block until the future is completed (i.e., the Runnable has executed) future.join(); System.out.println("runWithCustomExecutor done!"); }
Key Takeaways:
CompletableFuture.supplyAsync() initiates a non–blocking, asynchronous computation.
By default, tasks execute in the ForkJoinPool.commonPool(), but you can provide your custom Executor.
It provides a more modern, fluent, and readable approach to asynchronous programming in Java, especially when combined with other methods of CompletableFuture for chaining, combining, and handling results.