Learn how to implement Java records well, including validation, derived behavior, and where records should stop.
Implementing a record well is mostly about using the concise syntax without forgetting normal design discipline. A record can still enforce invariants, carry small behavior, and participate in a larger model.
The most important implementation technique is validating the record at construction time.
1public record EmailAddress(String value) {
2 public EmailAddress {
3 if (value == null || value.isBlank() || !value.contains("@")) {
4 throw new IllegalArgumentException("Invalid email address");
5 }
6 }
7}
This compact constructor keeps the record honest. Without it, a record can become just as sloppy as a weak ordinary class.
Records can have methods:
1public record TimeRange(Instant start, Instant end) {
2 public TimeRange {
3 if (start.isAfter(end)) {
4 throw new IllegalArgumentException("start must be before end");
5 }
6 }
7
8 public Duration duration() {
9 return Duration.between(start, end);
10 }
11}
That is good record usage because the behavior is directly tied to the data.
If the methods start turning into orchestration, repository access, or lifecycle management, the record is probably not the right abstraction anymore.
Records are strong public API types when:
They are weaker when you expect the public shape to change frequently or when encapsulation depends on hiding representation details.
Many small records do not need builders at all:
1public record UserView(UUID id, String name, String role) {}
If a type has only a few required fields and clear meaning, a builder may add ceremony rather than clarity. Builders are still useful when:
Implement records as compact, validated, immutable domain carriers. Let them hold data-adjacent behavior, but stop before they turn into disguised service objects.