Java Records — a practical guide to replacing boilerplate data classes with immutable, concise records in Java 16+. Covers constructors, validation, factory methods, and when to use records vs classes vs Lombok.
One of the most welcome additions in recent Java versions, records have quietly become a staple of my day-to-day Java. Before they arrived in Java 16, writing an immutable data class meant a wall of boilerplate: explicit fields, a constructor, getters, equals(), hashCode(), and toString(). Records collapse all of that into a single line. I’ve used them extensively — from modelling trade events in Mosaic’s real-time pipeline to handling pricing DTOs at Co-op and workflow events in ESG’s orchestration layer. Here’s a practical guide, grounded in how I actually use them.
Java records, introduced as a preview in Java 14 and finalized in Java 16, are a special kind of class designed for
immutable data carriers. They’re perfect for modeling data that doesn’t change, like a trade event or a product price.
In Mosaic’s pipeline, I used records to represent trade events, cutting boilerplate and making my code scream clarity.
Records automatically provide getters, equals(), hashCode(), and toString(), so you don’t have to write them
yourself.
ProTip: Use records for simple data holders to slash boilerplate and keep your code clean.
Before records, I’d write verbose classes for data objects. For example, in Co-op’s pricing system, a Price class
looked like this:
public class Price {
private final double amount;
private final String currency;
public Price(double amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public double getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Price price = (Price) o;
return Double.compare(price.amount, amount) == 0 &&
Objects.equals(currency, currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return "Price{amount=" + amount + ", currency='" + currency + "'}";
}
}
That’s a lot of code for a simple data holder! Records were introduced to eliminate this boilerplate, making immutable data classes concise and readable. In ESG’s BOL Engine, records let me focus on workflow logic instead of getter-setter noise.
Creating a record is dead simple. In Mosaic’s pipeline, I modeled a TradeEvent like this:
public record TradeEvent(String symbol, double price, int size) {
}
This one-liner gives you:
symbol, price, size).symbol(), not getSymbol()).equals(), hashCode(), and toString().You can use it like this:
TradeEvent event = new TradeEvent("AAPL", 150.0, 100);
System.out.println(event.symbol()); // AAPL
System.out.println(event); // TradeEvent[symbol=AAPL, price=150.0, size=100]
ProTip: Stick to records for immutable data to leverage their built-in features and avoid manual boilerplate.
Records are flexible. You can add custom constructors, methods, or static fields. In Co-op’s pricing system, I added
validation to a Price record:
public record Price(double amount, String currency) {
public Price {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("Currency cannot be null or blank");
}
}
public String formattedPrice() {
return String.format("%s %.2f", currency, amount);
}
}
Used like this:
Price price = new Price(19.99, "GBP");
System.out.println(price.formattedPrice()); // GBP 19.99
In Ribby Hall’s data sync, I added a static method to a Config record:
public record Config(String endpoint, int timeout) {
public static Config defaultConfig() {
return new Config("localhost:8080", 30);
}
}
Records are inherently immutable—their fields are final, and there are no setters. This makes them perfect for
thread-safe data models. In Mosaic’s pipeline, I used TradeEvent records to ensure trade data wasn’t modified
accidentally in multi-threaded streams. Immutability also simplifies reasoning about code, as I found in ESG’s workflow
engine where records prevented state-related bugs.
Records shine in several scenarios:
PriceDTO record to transfer pricing data,
reducing boilerplate.TradeEvent records modeled trade data cleanly.Config records held immutable settings.Here’s a PriceDTO example from Co-op:
public record PriceDTO(double amount, String currency) {
}
Used in a controller:
@RestController
public class PriceController {
@GetMapping("/prices")
public PriceDTO getPrice() {
return new PriceDTO(19.99, "GBP");
}
}
Records have saved me time and effort:
equals(). In Co-op’s system, records cut my Price class
from 30 lines to 3.toString(), equals(), and hashCode() reduce bugs.Records aren’t perfect. Here’s what I’ve run into:
Record). I worked
around this in Mosaic by using composition.Price record to handle this.ProTip: Use regular classes when you need mutability or inheritance, but lean on records for immutable data holders.
Regular classes require manual boilerplate for immutable data. In Co-op’s early pricing system, I wrote a verbose
Price class (see above). A Price record is just:
public record Price(double amount, String currency) {
}
Records are concise but lack mutability and inheritance, so I use classes for complex logic or mutable state in ESG’s engine.
Lombok’s @Data or @Value annotations reduce boilerplate, but they’re not part of the Java language and require
external dependencies. In Ribby Hall’s sync, I switched from Lombok to records for a Config class to avoid build
complexity:
@Data // Lombok
public class Config {
private final String endpoint;
private final int timeout;
}
Vs. a record:
public record Config(String endpoint, int timeout) {
}
Records are cleaner and dependency-free, which I prefer for long-term maintenance.
The Immutables library generates immutable classes with custom features, but it’s overkill for simple cases. In Mosaic’s
pipeline, I replaced an Immutables-based TradeEvent with a record, simplifying my codebase:
public record TradeEvent(String symbol, double price, int size) {
}
Use Immutables for advanced immutability needs, but records for straightforward data holders.
Here are real-world examples from my projects:
In Mosaic’s pipeline, I used a TradeEvent record:
public record TradeEvent(String symbol, double price, int size) {
}
Processed in a stream:
List<String> symbols = tradeEvents.stream()
.map(TradeEvent::symbol)
.collect(Collectors.toList());
In Co-op’s API, I used a PriceDTO record:
public record PriceDTO(double amount, String currency) {
}
Returned from a controller:
@GetMapping("/prices")
public PriceDTO getPrice() {
return new PriceDTO(19.99, "GBP");
}
In ESG’s BOL Engine, I validated a WorkflowEvent record:
public record WorkflowEvent(String id, String name) {
public WorkflowEvent {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("ID cannot be null or blank");
}
}
}
In Ribby Hall’s sync, I added a factory method to a Config record:
public record Config(String endpoint, int timeout) {
public static Config defaultConfig() {
return new Config("localhost:8080", 30);
}
}
Used like:
Config config = Config.defaultConfig();
Records are awesome, but I’ve hit bumps:
fieldName(), not getFieldName(). I tripped on this in Ribby
Hall’s sync—double-check your calls.ProTip: Use records for simple, immutable data, and profile their performance in streams with tools like VisualVM to catch overhead.
Java records have been a lifesaver in my projects. In Mosaic’s pipeline, they simplified trade event modeling, keeping my code clean and thread-safe. At Co-op, they streamlined pricing DTOs, cutting boilerplate. In ESG’s BOL Engine and Ribby Hall’s sync, they made immutable data a breeze. Records are perfect for beginners and pros alike—just define your fields and go. Start small: replace one verbose class with a record and see the difference. Check Oracle’s Java docs or my clean code tips here for more.
Have you used records to simplify your code? Share your wins with me here—I’d love to hear your story!