IntelliJ IDEA Java

Data Modeling with Records – 5 Examples for Busy Developers

If you talk about the syntax, Records seem to be a concise way of creating a new data type to store your immutable data. But what about its semantics? In other words, what does the term ‘Record’ imply? What are its features and limitations? What are the best places to use records, or the worst places to even think about them? 

In this blog post, I’ll try to answer these questions by using hands-on practical examples for busy developers like you. You’ll be surprised to discover how useful records are when used with other modern Java features like Record Patterns and Sealed Classes.

Let’s get started with our first example.

1. Concise, clean and simple way to define domain values, also referred to as Value Objects (VO)

With records, you can represent a domain value in an expressive and concise way, as compared to representing the same domain value using a regular class.

Before moving forward, let’s quickly understand what a Value Object is. Imagine you are creating a solution for students. One of their requirements is to be able to view a set of colors, say, 2, commonly referred to as a ‘Palette of 2’ – available as printed copies. They often use it to choose a set of colors for their projects. Here’s an example of what they use, identifying each using a name (like summer, winter, gold, pastel etc.):

In this situation, ‘Palette of 2 Colors’ is a Value Object – a concept from the domain model (actual business or problem use case) that represents a single or a set of values. It is uncommon for the palette colors to change once they are printed; in other words they are immutable. Each printed page has a HEX code written on its reverse, so that the students could use this value to recreate the same color in their online tools. Also, they use these codes to confirm with their teammates if they are using the same color palette:

We could encapsulate this concept using a value object, say, Palette2Colors, by defining a record with components name (of type String),color1 and color2 (of type java.awt.Color):

Here’s the preceding code for your reference:

public record Palette2Colors(String name,
                                		Color color1,
  										Color color2) {}

A record is an expressive way to declare that it encapsulates the state using 3 values – name, color1 and color2. The intent of the record can be expressed via just one line of code that captures its components.

A record is also concise because to encapsulate the same values using a regular class, you’ll need to define it as follows:

public class Palette2Colors {
   private final String name;
   private final Color color1;
   private final Color color2;

   public Palette2Colors(String name, Color color1, Color color2) {
       this.name = name;
       this.color1 = color1;
       this.color2 = color2;
   }

   public String name() {
       return name;
   }

   public Color color1() {
       return color1;
   }

   public Color color2() {
       return color2;
   }

   @Override
   public boolean equals(Object obj) {
       if (obj == this) return true;
       if (obj == null || obj.getClass() != this.getClass()) return false;
       var that = (Palette2Colors) obj;
       return Objects.equals(this.name, that.name) &&
      		 Objects.equals(this.color1, that.color1) &&
      		 Objects.equals(this.color2, that.color2);
   }

   @Override
   public int hashCode() {
       return Objects.hash(name, color1, color2);
   }
}

Let’s compare both these definitions. The concise code of records reduces your cognitive load – you can figure out what data Palette2Colors encapsulates by reading just a few lines of code:

Don’t let the conciseness of the records trick you. Behind the scenes, the Java compiler generates a default constructor, setter methods for all the record components and default implementations of the methods toString(), hashCode() and equals().

A lot of developers argue whether this is a real benefit because most IDEs can generate this code for you. Also, you could use libraries like Lombok to do so.

First, there is the gap between intentions and matching actions. Even though every developer can do it, all don’t do it. Records take away this uncertainty and enforces it at the language level, which is better. You can be 100% sure that if you have a record, you will definitely have implementations of all these methods. You might not realize but collection classes use equals() and hashCode() methods to determine the equality of your instances. The following test will pass for record Palette2Colors, but fail if you convert it to a class and delete its hashCode() and equals() method:

public void testWorkingWithCollections() {
   var pastel = new Palette2Colors("Pastel", Color.decode("#EAC7C7"), Color.decode("#A0C3D2"));
   var random = new Palette2Colors("Pastel", Color.decode("#EAC7C7"), Color.decode("#A0C3D2"));

   List<palette2colors> palette = new ArrayList<>();
   palette.add(pastel);

   Assert.assertTrue(palette.contains(random));
}

Now’s let’s revisit our record Palette2Colors. We need to add in checks to ensure non-null values are passed to its components. Java compiler creates default implementation of a constructor for a record. To modify that behavior, you can override it.

Unlike the constructor of a regular class that must specify the method parameters, a record can also define a compact constructor – a constructor that doesn’t define the method parameter list because it is identical to the list of components of a record:

Here’s the constructor code for your reference:

public Palette2Colors{
   Objects.requireNonNull(name, "Palette name shouldn't be null");
   Objects.requireNonNull(color1, "Palette's 1st color shouldn't be null");
   Objects.requireNonNull(color2, "Palette's 2nd color shouldn't be null");
}

If you are wondering if the preceding code misses assignment of values to the components of a record, don’t worry. The compiler does it. You can also create a canonical constructor, which means a full or complete constructor including initialization of the components of a record (or convert an existing compact constructor to a canonical constructor as follows):

Here’s the preceding code for your reference:

public record Palette2Colors(String name,
                            Color color1,
                            Color color2) {
   public Palette2Colors(String name, Color color1, Color color2) {
       Objects.requireNonNull(name, "Palette name shouldn't be null");
       Objects.requireNonNull(color1, "Palette's 1st color shouldn't be null");
       Objects.requireNonNull(color2, "Palette's 2nd color shouldn't be null");
       this.name = name;
       this.color1 = color1;
       this.color2 = color2;
   }
}

Also, you might have alternate ways of creating an instance of Palette2Colors, like passing an array for color1 and color2, instead of passing them as separate colors. When you create additional constructors in a record, they must call the canonical constructors:

Here’s the code for your reference:

public Palette2Colors(String name, Color[] colors) {
   this(name,
        Objects.requireNonNull(colors,"Palette Colors shouldn't be null")[0],
        colors[1]);
}

In this section, I talked about Value Objects. Let’s talk about the other similar concepts like Data Transfer objects (DTO). As the name suggests, DTOs are objects used to move data within or across applications, or over networks. Multiple calls to transfer individual data components is less efficient than a single call to transfer an aggregation of data. Unless you are thinking about changing the values of a DTO instance after its creation, Records are a good fit.

2. Defining expressive data models with ease

Imagine an application where you work with data downloaded as CSV (Comma Separated Values) files, say, for example data for monetary transactions for multiple banks. This application might be used to determine, say, total expenditure for NEFT transactions to a particular account name, or total cash withdrawn on a particular date or time, in a specified city. Let’s see how records can model this business domain with ease.

The following is an example for different kinds of transactions stored in a CSV file for bank transactions, such as, NEFT transfers, creation of a Fixed Deposit, swiping card at a store (Point-Of-Sale), and ATM cash withdrawal (other transactions types omitted to keep this example simple):

14-04-2022,NEFT/92665604/SU JOHN/ICICI/PrintFee,50000,0,52030.59
12-05-2022,TO For 920040032703660,100727,0,8263.23
24-07-2022,POS/THE DELHI COLLEGE/DELHI/240722/00:21,2200,0,2263.23
20-08-2022,ATM-CASH/DPRH114804/3092/200521/DELHI,,0,

Let me categorize the preceding date using a table, identifying the data:

If you look closely, you’ll notice that the transaction details are made up of multiple data items (separated using a backslash). Also, it isn’t the same across all kinds of transactions. Let’s break it further using the following table:

In my experience, I have seen (and written) code that extracts the description from the CSV files and stores it as a String in a database or another place. Be honest, have you done that too?

Perhaps you might also have seen code that documents business logic such as the transaction descriptions must start with a set of values. But how could anyone ensure that the comments are read and the instructions they define read?

If you read the first point mentioned above, you’ll remember that creating a record that models your business domain is simpler than creating a ‘full-fledged class’. So, let’s start by creating a record, say, BankTransaction, to encapsulate all the bank transaction details, as follows:

public record BankTransaction (LocalDate date,
                              TranDetails details,
                              Money debitAmt,
                              Money creditAmt,
                              Money accountBalance) { }

In the preceding example, we identified that the transaction details varied for each transaction type. Let’s create an interface, say, TransDetails, to represent it:

public interface TranDetails {}

Now, let us create records that represent the data for different kinds of transactions – POS, NEFT, ATMCash, FD, as follows:

   record POS(
           String paidTo,
           String city,
           LocalDate date,
           LocalTime time
   ) implements TranDetails { }

   record NEFT(
           String referenceNo,
           String accountFrom,
           String accountTo,
           String desc
   ) implements TranDetails { }

   record ATMCash(
           String ATMCode,
           String transCode,
           LocalDate date,
           LocalTime time,
           String city
   ) implements TranDetails { }

   record FD(String AccountNumber) 
     implements TranDetails { }

Let me modify the preceding code by declaring the interface TransDetails as a sealed interface that permits implementation by the records POS, NEFT, ATMCash and FD. By defining an interface (or a class) as sealed, as a developer, you can control which other classes are allowed to implement it. This results in better data modeling because no class is allowed to implement a sealed interface without its permission. Later in this section, you’ll see how sealed interfaces helps is deconstruction of records:

sealed public interface TranDetails permits POS, NEFT, ATMCash, FD {}

record POS(String paidTo,
           String city,
           LocalDate date,
           LocalTime time) 
       implements TranDetails { }

record NEFT(String referenceNo,
            String accountFrom,
            String accountTo,
            String desc) 
       implements TranDetails { }

record ATMCash(String ATMCode,
               String transCode,
               LocalDate date,
               LocalTime time,
               String city) 
implements TranDetails { }

record FD(String AccountNumber) 
implements TranDetails { }

When you store your data using the correct Java data types, you can decipher incorrect or invalid data and define rules on how to handle it, before it is accessed by your wider code base. I’ve witnessed data downloaded from the same bank that changed their date format randomly (DD-MM-YYY, DD/MM/YYY and DD/MM/YY). In one of the applications, I intercepted this changing date format and created the correct LocalDate instances before using the CSV data across my application.

This could also save you from ad-hoc methods that might be required to query these transaction details stored as, say, String data, searching for a description that starts with ‘POS’ or includes a specific date or time.

Let’s revisit the CSV data we talked about earlier:

14-04-2022,NEFT/92665604/SU JOHN/ICICI/PrintFee,50000,0,52030.59
12-05-2022,TO For 920040032703660,100727,0,8263.23
24-07-2022,POS/THE DELHI COLLEGE/DELHI/140421/00:21,2200,0,2263.23
20-08-2022,ATM-CASH/DPRH114804/3092/200521/DELHI,,0,

Record patterns, introduced in Java 19, simplify how you can deconstruct a record (and nested records), extracting its components. Following is an example of a method processTransaction, that accepts an object of type transaction and can switch over the details of the transaction, executing a method for each case:

void processTransaction(BankTransaction transaction) {
   Objects.requireNonNull(transaction);
   switch (transaction.tranDetails()) {
       case TranDetails.POS pos -> log(pos.paidTo());
       case TranDetails.FD fd -> logFD(fd.AccountNumber());
       case TranDetails.ATMCash atmCash -> traveledTo(atmCash.city());
       case TranDetails.NEFT (var referenceNo,
                              var from,
                              var to,
                              var desc) -> processNEFT(from.toUpperCase() + to.toUpperCase());
   }
}

Since TransDetails is a sealed interface, the compiler can determine that it defines a definitive set of records that implement it. So, you can iterate over its values exhaustively).

If you are new to modern Java features, access this link to know more about records, this link to find out about Record Patterns, and Record patterns for practical examples for Java developers on Pattern Matching.

As a developer, note the multiple data items that might be represented or stored as a single data item. For an example, the CSV data I used in this section could be represented as follows:

{
    "Date": "14-04-2022",
    "Transaction": "NEFT/92665604/SU JOHN/ICICI/PrintFee",
    "Debit": "50000",
    "Credit": "0",
    "Balance": "52030.59"
}

And also as follows:

{
  "date": "14-04-2022",
  "transaction_details": {
    "type": "NEFT",
    "reference_number": "92665604",
    "beneficiary_name": "SU JOHN",
    "bank": "ICICI",
    "description": "PrintFee"
  },
  "debit_amount": 50000,
  "credit_amount": 0,
  "balance": 52030.59
}

As a developer, you are the best person to determine how you want to organize or model your data. For an example, for the preceding JSON, which of these do you want as another type:

  • Transaction details: type + reference_number + beneficary_name + bank + description Beneficiary name (first name + last name),
  • Date (day+month+year),
  • Transaction amounts (Currency + amount)?

3. Refactoring methods by using local records

Often you refactor code to separate concerns within a method. For example, in the following example, the first three lines of code are used to calculate the circumference and area of a circle, based on the radius that is passed to the method as a method parameter. The last line in this method outputs the calculated values.

void processShape(double radius) {
   double PI = 3.14;
   double circumference = 2 * PI * radius;
   double area = PI * radius * radius;
   System.out.println("circumference: " + circumference + "; area: " + area);
}

Imagine you want to separate these concerns by extracting the top three lines of code to another method. But, this is not feasible via regular extract method techniques because it includes multiple output variables.

Local records to the rescue. IntelliJ IDEA can detect this situation and offer to create a local record and define the multiple values as its components. At present, this refactoring is implemented as a two step process. In the first step, you can modify the name of the local record and name of the local variable used to refer to the record instance. In the second step, you can modify the name of the extracted method. Following is the gif, that captures this two-step, helpful refactoring:

4. Frameworks or libraries that modify states

You can’t use records with frameworks or libraries, like Hibernate, to define your JPA entity classes. There is a mismatch between the JPA specifications and implicit behavior of records.

The JPA specifications mandate that an entity class can’t be final, must provide a default, that is, no argument constructor, must define at least one component and a few others.

On the other hand, records are implicitly final classes and are designed to represent immutable data. A record doesn’t have a no-argument constructor. The default no-argument constructors assign default values to the instance variables of a class (usually 0, 0.0, or null). It doesn’t make sense for a record to assign such default values because its components are final; they can’t be reassigned another value if the default no-argument constructor executes for them.

What about the other frameworks or libraries? The answer to this question depends on how they are treating records. If they are trying to change the state of a record using reflection, it won’t work. Consider the following record, say, MyPointAsRecord, that defines two components – xPos and yPos:

public record MyPointAsRecord(int xPos, int yPos) { }
}

If the framework you are using tries to modify the state of the final component xPos via reflection, as shown in the following method, it will fail during execution:

void changeFinalForRecords() throws NoSuchFieldException, IllegalAccessException {
   final var myPointAsRecord = new MyPointAsRecord(12, 35);

   Field xPosField = myPointAsRecord.getClass().getDeclaredField("xPos");
   xPosField.setAccessible(true);

   xPosField.setInt(myPointAsRecord, 1000);
   System.out.println(myPointAsRecord);
}

Now compare the same record defined as a regular class – but with components or instance variables defined as private and final members.

public final class MyPointAsClass {
   private final int xPos;
   private final int yPos;

   public MyPointAsClass(int xPos, int yPos) {
       this.xPos = xPos;
       this.yPos = yPos;
   }

   public int xPos() {
       return xPos;
   }

   public int yPos() {
       return yPos;
   }

   @Override
   public boolean equals(Object obj) {
       if (obj == this) return true;
       if (obj == null || obj.getClass() != this.getClass()) return false;
       var that = (MyPointAsClass) obj;
       return this.xPos == that.xPos && this.yPos == that.yPos;
   }

   @Override
   public int hashCode() {
       return Objects.hash(xPos, yPos);
   }
}

In this case, a library or a framework might be able to change the value of its private and final field via reflection:

void changeFinalForNonRecords() throws NoSuchFieldException, IllegalAccessException {
   final var myPointAsClass = new MyPointAsClass(10, 20);

   Field xPosField = myPointAsClass.getClass().getDeclaredField("xPos");
   xPosField.setAccessible(true);

   xPosField.setInt(myPointAsClass, 1000);
   System.out.println(myPointAsClass);
}

In short, when you think about using records with any other framework or a library, figure out how they want to use your records. Also, records are a great way to work with multithreaded environments because most of the time you can be sure that your data won’t change (by mistake or intentionally).

5. Powerful and concise data processing

You can also use records to elegantly model and process data structures in your code. You can create powerful, yet concise and expressive code to process your data by using a combination of records, record patterns, switch expressions, and sealed classes. Here’s an example of a sealed interface TwoDimensional, which is implemented by records Point, Line, Triangle, and Square:

sealed interface TwoDimensional {}
record Point (int x, int y) implements TwoDimensional { }
record Line    ( Point start,
                Point end) implements TwoDimensional { }
record Triangle( Point pointA,
                Point pointB,
                Point PointC) implements TwoDimensional { }
record Square  ( Point pointA,
                Point pointB,
                Point PointC,
                Point pointD) implements TwoDimensional { }

The following method defines a recursive method process that uses a switch construct to return the sum of the x and y coordinates for all of the points in a two dimensional figure like a Line, Triangle, or Square:

static int process(TwoDimensional twoDim) {
  return switch (twoDim) {
      case Point(int x, int y) -> x + y;
      case Line(Point a, Point b) -> process(a) + process(b);
      case Triangle(Point a, Point b, Point c) ->
                                process(a) + process(b) + process(c);
      case Square(Point a, Point b, Point c, Point d) ->
                                process(a) + process(b) + process(c) + process(d);
  };
}

IntelliJ IDEA also displays the recursive call icon in the gutter for this method:

Summary

In this blog post, I covered records beyond its syntax, talked about its semantics, and shared where you can use them using 5 practical examples.

Two heads are better than one. When used with other modern Java features like Pattern matching and sealed classes, you can create expressive, powerful and useful business models in your application using records – all with much ease. You can also use records to model your seemingly non-obvious data, like the complex return types from a method or data structures.

Let me know what other topics you’d like me to cover in my next blog post.

Until then, happy coding!

image description