Java Records: A WebFlux and Spring Data Example
When defining classes for a simple aggregation of values, Java developers have traditionally relied on constructors, accessors, equals()
, hashCode()
and toString()
, an error-prone practice that has low value and shifts the focus away from modeling immutable data. Java records were introduced as a first preview in JDK 14 in order to simplify how we write data carrier classes. The second preview came in JDK 15 and the finalized feature arrived in JDK 16. A summary of this history is available in the JDK Enhancement Proposal JEP 395.
While code generators can be used to reduce boilerplate code, the goals of the record
proposals focus on its semantics. In this post, let’s explore Java records’ features and advantages, and apply them for building a REST API and querying a database.
Prerequisites:
Table of Contents
- The
record
keyword - Java record restrictions and rules
- Use Java records with Spring WebFlux and Spring Data
- Java records advantages and limitations
- Learn more about Java and Spring
The record
keyword
The record
is a new type of declaration, a restricted form of class that acts as a transparent carrier for immutable data. Let’s start the exploration by defining a simple data type EndOfGame
as a record
:
import java.time.LocalDate;
import java.time.LocalTime;
public record EndOfGame(String id, LocalDate date, LocalTime timeOfDay,
String mentalState, Integer damageTaken,
Integer damageToPlayers, Integer damageToStructures) {
}
As you can see in the example above, the record
has a name, EndOfGame
in this case. What looks like a constructor signature is the state description or header, declaring the components of the record
.
The following members are acquired automatically with the declaration:
- A private final field and a public read accessor for each component of the state description
- A public canonical constructor with the same signature as the state description, which initializes each field from the corresponding argument
- Implementation of
equals()
,hashCode()
andtoString()
The tests in the EndOfGameTest
class below verify that the automatic members are indeed available:
package com.okta.developer.records.domain;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDate;
import java.time.LocalTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class EndOfGameTest {
private static final Logger logger = LoggerFactory.getLogger(EndOfGameTest.class);
private EndOfGame createEndOfGame() {
return new EndOfGame("1", LocalDate.of(2018, 12, 12),
LocalTime.of(15, 15), "sober",
10, 10, 10);
}
@Test
public void equalsTest() {
EndOfGame eog1 = createEndOfGame();
EndOfGame eog2 = createEndOfGame();
assertTrue(eog1.equals(eog2));
assertEquals(eog1, eog2);
assertEquals(eog1.hashCode(), eog2.hashCode());
}
@Test
public void toStringTest() {
EndOfGame eog = createEndOfGame();
logger.info(eog.toString());
assertEquals("EndOfGame[id=1, date=2018-12-12, timeOfDay=15:15, mentalState=sober, " +
"damageTaken=10, damageToPlayers=10, damageToStructures=10]",
eog.toString());
}
@Test
public void accessorTest() {
EndOfGame eog = createEndOfGame();
assertEquals("sober", eog.mentalState());
}
}
The automatic canonical constructor is used for creating a sample EndOfGame
in the method createEndOfGame()
.
In the equalsTest()
above, eog1
has the same state as eog2
, so eog1.equals(eog2)
is true
. This also implies both instances have the same hashCode
.
Automatic read accessors have the same name and return type as the component. Note there is no get*
prefix in the read accessor name, which is the same name as the component, as illustrated in the accessorTest()
.
Java record restrictions and rules
While record
provides a more concise syntax and semantics designed to help to model data aggregates, as stated before, a record
is a restricted form of a class. Let’s have a brief look at those restrictions.
Inheritance, extensibility, and immutability
A record
is implicitly final, and cannot be abstract. You cannot enhance it later by extension, as the compiler will output the following error:
Cannot inherit from final 'com.okta.developer.records.EndOfGame'
A record
cannot extend any class, not even its implicit superclass Record
. But it can implement interfaces. Using the extends
with records clause will cause the following error:
No extends clause allowed for record
A record
does not have write accessors and the implicitly declared fields are final, and not modifiable via reflection. Moreover, a record
cannot declare instance fields outside the record
header. Records embody an immutable by default policy, usually applied to data carrier classes.
Instance field is not allowed in record
Cannot assign a value to final variable 'id'
Read accessors can be declared explicitly, but should never silently alter the record state. Review record semantics before making explicit declarations of automatic members.
A record without any constructor declaration will be given a canonical constructor with the same signature as the header, which will assign all the private fields from the arguments. The canonical constructor can be declared explicitly in the standard syntax, or in a compact form:
public EndOfGame {
Objects.requireNonNull(date);
Objects.requireNonNull(timeOfDay);
Objects.requireNonNull(mentalState);
Objects.requireNonNull(damageTaken);
Objects.requireNonNull(damageToPlayers);
Objects.requireNonNull(damageToStructures);
}
In the compact form, the parameters are implicit, the private fields cannot be assigned inside the body, and are automatically assigned at the end. This enables a focus on validation, making defensive copies of mutable values, or some other value processing.
Serialization, encoding, and mapping
Record instances can extend Serializable
, but the serialization and deserialization processes cannot be customized. Serialization and deserialization methods like writeObject
, readObject
can be implemented, but will be ignored.
As spring-web
module provides Jackson with JSON encoders and decoders, and Java record support was added to Jackson in release 2.12, records can be used for REST API request and response mapping.
Records cannot be used as entities with JPA/Hibernate. JPA entities must have a no-args constructor, and must not be final, two requirements that record
will not support. There are additional issues with JPA and records too.
Spring Data modules that do not use the object mapping of the underlying data store (like JPA) support records, as persistence construction detection works as with other classes. For Spring Data MongoDB, the general recommendation is to stick to immutable objects, because they are straightforward to materialize by calling the constructor. An all-args constructor, allows materialization to skip property population for optimal performance. This too is recommended, and Java record semantics align with these guidelines.
Use Java records with Spring WebFlux and Spring Data
While searching for a dataset for this tutorial, I came across a collection of 87 end game statistics from a single player, for a popular game. As the author included his mental state in the game play data, I decided to use Java records for building a basic average query and finding out if the player’s performance was significantly altered when sober vs when intoxicated.
Let’s jump ahead and build a secured REST Api using the EndOfGame
record, Spring Boot, MongoDB and Okta authentication. With the help of Spring Initializr create a WebFlux application, from the web UI or with HTTPie:
https -d start.spring.io/starter.zip bootVersion==2.5.6 \
baseDir==java-records \
groupId==com.okta.developer.records \
artifactId==records-demo \
name==java-records \
packageName==com.okta.developer.records \
javaVersion==17 \
dependencies==webflux,okta,data-mongodb-reactive
Note: Although Java records have been available since release 14, the Spring Initializr Web UI only lets you select Java Long Term Support (LTS) releases.
Extract the Maven project, and edit pom.xml
to add two more required dependencies for this tutorial, MongoDB Testcontainers Module for testing database access, and spring-security-test
:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>5.5.1</version>
<scope>test</scope>
</dependency>
Before you begin, you’ll need a free Okta developer account. Install the Okta CLI and run okta register
to sign up for a new account. If you already have an account, run okta login
.
Then, run okta apps create
. Select the default app name, or change it as you see fit.
Choose Web and press Enter.
Select Okta Spring Boot Starter.
Accept the default Redirect URI values provided for you. That is, a Login Redirect of http://localhost:8080/login/oauth2/code/okta
and a Logout Redirect of http://localhost:8080
.
What does the Okta CLI do?
The Okta CLI will create an OIDC Web App in your Okta Org. It will add the redirect URIs you specified and grant access to the Everyone group. You will see output like the following when it’s finished:
Okta application configuration has been written to:
/path/to/app/src/main/resources/application.properties
Open src/main/resources/application.properties
to see the issuer and credentials for your app.
okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS
NOTE: You can also use the Okta Admin Console to create your app. See Create a Spring Boot App for more information.
Rename application.properties
to application.yml
, and add the following content:
okta:
oauth2:
issuer: https://{yourOktaDomain}/oauth2/default
client-id: {clientId}
client-secret: {clientSecret}
spring:
data:
mongodb:
port: 27017
database: fortnite
logging:
level:
org.springframework.data.mongodb: TRACE
Add an EndOfGame
record under the package com.okta.developer.records.domain
. Annotate the record
with @Document(collection = "stats")
to let MongoDB map EndOfGame
to the stats
collection. As you can see, a record class can be annotated. The class should look like this:
package com.okta.developer.records.domain;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Objects;
@Document(collection = "stats")
public record EndOfGame(String id, LocalDate date, LocalTime timeOfDay,
String mentalState, Integer damageTaken,
Integer damageToPlayers, Integer damageToStructures) {
public EndOfGame {
Objects.requireNonNull(date);
Objects.requireNonNull(timeOfDay);
Objects.requireNonNull(mentalState);
Objects.requireNonNull(damageTaken);
Objects.requireNonNull(damageToPlayers);
Objects.requireNonNull(damageToStructures);
}
}
Add a new record for the mental state query MentalStateDamage
:
package com.okta.developer.records.domain;
public record MentalStateDamage(String mentalState,
Double damageToPlayers,
Double damageToStructures,
Double damageTaken) {
}
Create the package com.okta.developer.records.repository
and add a MentalStateStatsRepository
interface:
package com.okta.developer.records.repository;
import com.okta.developer.records.domain.MentalStateDamage;
import reactor.core.publisher.Flux;
public interface MentalStateStatsRepository {
Flux<MentalStateDamage> queryMentalStateAverageDamage();
}
Add the implementation MentalStateStatsRepositoryImpl
to retrieve the average damage in each category, for each mental state:
package com.okta.developer.records.repository;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import reactor.core.publisher.Flux;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
public class MentalStateStatsRepositoryImpl implements MentalStateStatsRepository {
private final ReactiveMongoTemplate mongoTemplate;
@Autowired
public MentalStateStatsRepositoryImpl(ReactiveMongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Flux<MentalStateDamage> queryMentalStateAverageDamage() {
Aggregation aggregation = newAggregation(
group("mentalState")
.first("mentalState").as("mentalState")
.avg("damageToPlayers").as("damageToPlayers")
.avg("damageTaken").as("damageTaken")
.avg("damageToStructures").as("damageToStructures"),
project("mentalState", "damageToPlayers", "damageTaken", "damageToStructures")
);
return mongoTemplate.aggregate(aggregation, EndOfGame.class, MentalStateDamage.class);
}
}
Note: The Impl
suffix is required for customizing individual repositories with Spring Data
Create the StatsRepository
interface, extending the MentalStateStatsRepository
:
package com.okta.developer.records.repository;
import com.okta.developer.records.domain.EndOfGame;
import org.springframework.data.repository.reactive.ReactiveSortingRepository;
public interface StatsRepository extends ReactiveSortingRepository<EndOfGame, String>, MentalStateStatsRepository {
}
After the import,the sample dataset, will create strings for the date and time values, in a custom format. Create the following converters to map String
to LocalDate
and LocalTime
:
package com.okta.developer.records.repository;
import org.springframework.core.convert.converter.Converter;
import java.time.LocalDate;
public class LocalDateConverter implements Converter<String, LocalDate> {
@Override
public LocalDate convert(String s) {
return LocalDate.parse(s);
}
}
package com.okta.developer.records.repository;
import org.springframework.core.convert.converter.Converter;
import java.time.LocalTime;
public class LocalTimeConverter implements Converter<String, LocalTime> {
@Override
public LocalTime convert(String s) {
return LocalTime.parse(s);
}
}
Add a MongoConfiguration
class in the package com.okta.developer.records.configuration
, to register the converters:
package com.okta.developer.records.configuration;
import com.okta.developer.records.repository.LocalDateConverter;
import com.okta.developer.records.repository.LocalTimeConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
import java.util.Arrays;
@Configuration
public class MongoConfiguration {
@Bean
public MongoCustomConversions mongoCustomConversions() {
return new MongoCustomConversions(
Arrays.asList(
new LocalDateConverter(),
new LocalTimeConverter()));
}
}
Add a StatsService
interface in the com.okta.developer.records.service
package:
package com.okta.developer.records.service;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import reactor.core.publisher.Flux;
public interface StatsService {
Flux<MentalStateDamage> queryMentalStateAverageDamage();
Flux<EndOfGame> getAll();
}
Add a DefaultStatsService
class for the implementation in the com.okta.developer.records.service
package:
package com.okta.developer.records.service;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import com.okta.developer.records.repository.StatsRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class DefaultStatsService implements StatsService {
@Autowired
private StatsRepository statsRepository;
@Override
public Flux<MentalStateDamage> queryMentalStateAverageDamage() {
return statsRepository.queryMentalStateAverageDamage();
}
@Override
public Flux<EndOfGame> getAll() {
return statsRepository.findAll();
}
}
Add a StatsController
class in the com.okta.developer.records.controller
package:
package com.okta.developer.records.controller;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import com.okta.developer.records.service.StatsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
public class StatsController {
@Autowired
private StatsService statsService;
@GetMapping("/endOfGame")
public Flux<EndOfGame> getAllEndOfGame(){
return statsService.getAll();
}
@GetMapping("/mentalStateAverageDamage")
public Flux<MentalStateDamage> getMentalStateAverageDamage(){
return statsService.queryMentalStateAverageDamage();
}
}
The controller enables the /endOfGame
endpoint to get all entries, and the /mentalStateAverageDamage
endpoint, that returns the damage in each category, as in average by mental state.
Test Java records in the web layer
Create a StatsControllerTest
class in the package com.okta.developer.records.controller
under the src/test
folder, to verify the endpoints’ basic functionality with a web test. In this test, only the web slice is verified:
package com.okta.developer.records.controller;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import com.okta.developer.records.service.StatsService;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import java.time.LocalDate;
import java.time.LocalTime;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin;
@WebFluxTest
public class StatsControllerTest {
private static Logger logger = LoggerFactory.getLogger(StatsControllerTest.class);
@MockBean
private StatsService statsService;
@Autowired
private WebTestClient webTestClient;
@Test
public void testGet_noAuth_returnsNotAuthorized(){
webTestClient
.get().uri("/endofgame")
.exchange()
.expectStatus().is3xxRedirection();
}
@Test
public void testGet_withOidcLogin_returnsOk(){
EndOfGame endOfGame = new EndOfGame("1", LocalDate.now(), LocalTime.now(), "happy", 1, 1, 1);
given(statsService.getAll()).willReturn(Flux.just(endOfGame));
webTestClient.mutateWith(mockOidcLogin())
.get().uri("/endOfGame")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody()
.jsonPath("$.length()").isNumber()
.jsonPath("$.length()").isEqualTo("1")
.jsonPath("$[0].mentalState").isEqualTo("happy")
.jsonPath("$[0].damageTaken").isNumber()
.jsonPath("$[0].damageToPlayers").isNumber()
.jsonPath("$[0].damageToStructures").isNumber()
.jsonPath("$[0].date").isNotEmpty()
.jsonPath("$[0].timeOfDay").isNotEmpty()
.consumeWith(response -> logger.info(response.toString()));
}
@Test
public void testGetMentalStateAverageDamage_withOidcLogin_returnsOk(){
MentalStateDamage mentalStateDamage = new MentalStateDamage("happy", 0.0, 0.0, 0.0);
given(statsService.queryMentalStateAverageDamage()).willReturn(Flux.just(mentalStateDamage));
webTestClient
.mutateWith(mockOidcLogin())
.get().uri("/mentalStateAverageDamage")
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody()
.jsonPath("$.length()").isEqualTo("1")
.jsonPath("$.[0].mentalState").isEqualTo("happy")
.consumeWith(response -> logger.info(response.toString()));
}
}
Run the tests with the following Maven command:
./mvnw test -Dtest=StatsControllerTest
The /mentalStateAverageDamage
test above also verifies that the MentalStateDamage
record type is correctly handled when used as a response body. For the /endOfGame
you should see response logs similar to this:
[
{
"id":"1",
"date":"2021-10-21",
"timeOfDay":"12:02:34.233944363",
"mentalState":"happy",
"damageTaken":1,
"damageToPlayers":1,
"damageToStructures":1
}
]
Test Java records in the database layer
Download the test dataset from GitHub with HTTPie, and copy it to src/test/resources/stats.json
:
https -d raw.githubusercontent.com/oktadev/okta-java-records-example/9a60f81349cdffebbe001719256b0883493f987d/src/test/resources/stats.json
mkdir src/test/resources
mv stats.json src/test/resources/.
Create the StatsRepositoryTest
class in the package com.okta.developer.records.repository
under the src/test
folder, to verify the database slice:
package com.okta.developer.records.repository;
import com.okta.developer.records.configuration.MongoConfiguration;
import com.okta.developer.records.domain.EndOfGame;
import com.okta.developer.records.domain.MentalStateDamage;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration;
import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
import org.springframework.context.annotation.Import;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DataMongoTest(excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)
@Import(MongoConfiguration.class)
public class StatsRepositoryTest {
private static final Logger logger = LoggerFactory.getLogger(StatsRepositoryTest.class);
@Autowired
private StatsRepository statsRepository;
private static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:bionic"))
.withExposedPorts(27017)
.withCopyFileToContainer(MountableFile.forClasspathResource("stats.json"),
"/stats.json")
.withEnv("MONGO_INIT_DATABASE", "fortnite");
@BeforeAll
public static void setUp() throws IOException, InterruptedException {
mongoDBContainer.setPortBindings(List.of("27017:27017"));
mongoDBContainer.start();
Container.ExecResult result = mongoDBContainer.execInContainer("mongoimport",
"--verbose", "--db=fortnite", "--collection=stats", "--file=/stats.json", "--jsonArray");
logger.info(result.getStdout());
logger.info(result.getStderr());
logger.info("exit code={}", result.getExitCode());
}
@Test
public void testGetAll(){
Flux<EndOfGame> stats = statsRepository.findAll();
List<EndOfGame> result = stats.collectList().block();
assertThat(result).isNotEmpty();
assertThat(result).size().isEqualTo(87);
}
@Test
public void testQueryMentalStateAverageDamage(){
Flux<MentalStateDamage> stats = statsRepository.queryMentalStateAverageDamage();
List<MentalStateDamage> result = stats.collectList().block();
assertThat(result).isNotEmpty();
assertThat(result).size().isEqualTo(2);
assertThat(result.get(0).mentalState()).isIn("sober", "high");
assertThat(result.get(1).mentalState()).isIn("sober", "high");
logger.info(result.get(0).toString());
logger.info(result.get(1).toString());
}
@AfterAll
public static void tearDown(){
mongoDBContainer.stop();
}
}
@DataMongoTest
configures the data layer for testing. For this test, I evaluated using repository populator, but decided to go for a Testcontainers MongoDB instance instead, to reuse the import process and data.json
file later with Docker Compose. Using a container MongoDB instance requires disabling the EmbeddedMongoAutoConfiguration
.
In the setUp()
above, the mongoimport
tool is executed in the test container, initializing the stats
collection with the sample dataset.
Run the tests with the following Maven command:
./mvnw test -Dtest=StatsRepositoryTest
If the import runs successfully, the following line should appear in the test logs:
87 document(s) imported successfully. 0 document(s) failed to import.
Also, you can inspect the response in the logs for the average damage test:
MentalStateDamage[mentalState=sober, damageToPlayers=604.3777777777777, damageToStructures=3373.511111111111, damageTaken=246.46666666666667]
MentalStateDamage[mentalState=high, damageToPlayers=557.547619047619, damageToStructures=2953.8571428571427, damageTaken=241.71428571428572]
For this single-player dataset, the damage taken or inflicted was not orders of magnitude different when sober than when high.
Run the application
Create a docker
folder in the root of the project, and add the following docker-compose.yml
file there:
version: "3.1"
services:
mongodb:
image: mongo:bionic
environment:
- MONGO_INIT_DATABASE=fortnite
ports:
- "27017:27017"
volumes:
- ../src/test/resources/stats.json:/seed/stats.json
- ./initdb.sh:/docker-entrypoint-initdb.d/initdb.sh
demo:
image: records-demo:0.0.1-SNAPSHOT
ports:
- "8080:8080"
environment:
- SPRING_DATA_MONGODB_HOST=mongodb
depends_on:
- mongodb
Add also the initdb.sh
script in the docker
folder, to import the test data into MongoDB, with the following content:
mongoimport --verbose --db=fortnite --collection=stats --file=/seed/stats.json --jsonArray
In the project root, generate the application container image with the following Maven command:
./mvnw spring-boot:build-image
Run the application with Docker Compose:
cd docker
docker-compose up
Once the services are up, go to http://localhost:8080/mentalStateAverageDamage
, and you should see the Okta login page:
Sign in with your Okta credentials, and if successful, it will redirect to the /mentalStateAverageDamage
endpoint, and you should see a response body like the following:
[
{
"mentalState":"sober",
"damageToPlayers":604.3777777777777,
"damageToStructures":3373.511111111111,
"damageTaken":246.46666666666667
},
{
"mentalState":"high",
"damageToPlayers":557.547619047619,
"damageToStructures":2953.8571428571427,
"damageTaken":241.71428571428572
}
]
Java records advantages and limitations
While Java record is more concise for declaring data carrier classes, the “war on boilerplate” is not a goal of this construct, Records are not meant to add features like properties or annotation-driven code generation, as Project Lombok does. Record semantics provide benefits for modeling an immutable state data type. No hidden state is allowed, as no instance fields can be defined outside the header, hence the transparent claim. Compiler generated equals()
and hashCode()
avoid error-prone coding. Serialization and deserialization into JSON are straightforward thanks to its canonical constructor. Summarizing some of the Java Record features discussed in this post:
Advantages
- Concise syntax
- Immutable state
- Compiler generated
equals()
andhashCode()
- Straightforward JSON serialization and deserialization
Limitations
- Immutable state
- Cannot be used as JPA/Hibernate entities
- Cannot be extended or inherit a class
Learn more about Java and Spring
I hope you enjoyed this tutorial and learned about Java record semantics, its benefits, and limitations. Before choosing this feature, make sure to find out if your favorite frameworks support it. Fortunately, Spring Boot support for Java Records was recently added in 2.5.x releases through Jackson 2.12.x. I was not able to find comments about records in Spring Data documentation.
To continue learning about Java records, Okta security, and Spring WebFlux, check out the links below:
- java.lang.Record
- Reactive Java Microservices with Spring Boot and JHipster
- R2DBC and Spring for Non-Blocking Database Access
- A Quick Guide to Spring Cloud Stream
- How to Use Client Credentials Flow with Spring Security
- Better Testing with Spring Security Test
You can find the completed code for this tutorial on GitHub in the oktadev/okta-java-records-example repository.
If you liked this tutorial, chances are you will like others we publish. Please follow @oktadev on Twitter and subscribe to our YouTube channel to get notified when we publish new developer tutorials.
Okta Developer Blog Comment Policy
We welcome relevant and respectful comments. Off-topic comments may be removed.