How to create a class - Best Practices


Introduction
We will explain how to create an object in Java. It has evolved from the old Javabeans to the use of features in the latest versions of the language.
Setters and getters
We have a Person with three attributes: name, age and gender.
import java.util.Objects;
public class Person {
private String name;
private int age;
private String gender;
public Person(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public Gender getGender() {
return gender;
}
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
public void setGender(String gender) {
this.gender = gender;
}
@Override
public String toString() {
return "Person{" + "name='" + name + '\'' + ", age=" + age + ", gender='" + gender + '\'' + '}';
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.getAge() && name.equals(person.getName()) && gender.equals(person.getGender());
}
@Override
public int hashCode() {
return Objects.hash(name, age, gender);
}
}
Any IDE can generate all the above methods. But...... we can do it better.
Validation
Imagine the client wants to create a Person with a negative age or a with no name. Our API must take care of that in the constructor:
public Person(String name, int age, String gender) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Invalid name");
}
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
if (gender == null || gender.isBlank()) {
throw new IllegalArgumentException("Invalid gender");
}
this.name = name;
this.age = age;
this.gender = gender;
}
Static factory methods
Instead of having the client call the traditional constructor, our API can provide a number of descriptive methods, so that the client can choose the most appropriate one. This pattern is very common in the JDK. For example, the Instance
class (from the Java Time API) includes static factory methods such as now()
, of()
, from()
, and parse()
.
public static Person of(String name, int age, Gender gender) {
return new Person(name, age, gender);
}
public static Person createATwentyYearOldMale(String name) {
return new Person(name, 20, Gender.MALE);
}
public static Person createATwentyYearOldFemale(String name) {
return new Person(name, 20, Gender.FEMALE);
}
public enum Gender {
MALE,
FEMALE
}
Thanks to the enumeration class, we change the gender parameter from String type to Gender type. This allows to catch errors in compilation time.
Person person = Person.of("John", 25, Gender.MALE);
var person2 = Person.createATwentyYearOldMale("John");
var person3 = Person.of("John", 25, "men"); // Compilation error
It is important to note that the constructor is now private, so that the client cannot create directly objects of this class:
var person = new Person("Alice", 25, Gender.FEMALE); // Compilation error
Other benefit of having the constructor private is that the client cannot extend the Person class:
class Woman extends Person { // Compilation error
...................
}
This is a good thing, because we want to be able to control what the client can do with our API.
Non extensible class
However, to make the intent clear in the Javadoc documentation, we can use the final modifier:
public final class Person {
...................
}
We will talk about restricted inheritance later.
No setters
Although the setter methods were heavily used in the Javabeans days, programmers came to realize that immutable objects made in in many cases the code easier to read, especially in concurrent applications. In other words, creating an object every time an attribute changes its value. This is quite similar to how purely functional languages work.
public Person withName(String newName) {
return new Person(newName, this.age, this.gender);
}
public Person withAge(int newAge) {
return new Person(this.name, newAge, this.gender);
}
public Person withGender(Gender newGender) {
return new Person(this.name, this.age, newGender);
}
and make the attributes immutable, too:
private final String name;
private final int age;
private final Gender gender;
The with
prefix is a common convention and these methods are called withers
.
Records
A record is a class that is immutable(its attributes cannot be changed) and final. It was introduced as a preview feature in Java 14 and the first LTS version that included it was Java 17.
public record Person(String name, int age, Gender gender) {}
The record class automatically generates private final attributes, and implements the equals()
, hashCode()
, and toString()
methods.
We only have to write this code:
public record Person(String name, int age, Gender gender) {
// Validation rules
public Person { // The compact constructor doesn't have any parameters
if (name == null || name.isBlank())
throw new IllegalArgumentException("Name cannot be null or blank");
if (age < 0)
throw new IllegalArgumentException("Age cannot be negative");
if (gender == null || gender.isBlank())
throw new IllegalArgumentException("Invalid gender");
}
// Static factory methods
public static Person of(String name, int age, Gender gender) {
return new Person(name, age, gender);
}
public static Person createATwentyYearOldMale(String name) {
return new Person(name, 20, Gender.MALE);
}
public static Person createATwentyYearOldFemale(String name) {
return new Person(name, 20, Gender.FEMALE);
}
// Wither-methods
public Person withName(String newName) {
return new Person(newName, this.age, this.gender);
}
public Person withAge(int newAge) {
return new Person(this.name, newAge, this.gender);
}
public Person withGender(Gender newGender) {
return new Person(this.name, this.age, newGender);
}
}
The public accessor methods generated for each component have the same name as the corresponding attribute, they are not getters anymore:
var personJohn = Person.of("John", 25, Gender.MALE);
System.out.println("Name: " + personJohn.name()); // Accesor method if Person is a record
System.out.println("Name: " + personJohn.getName()); // Compiler error if Person is a record
There are two caveats with records:
The public constructor of a record, called canonical constructor, is part of the interface. It can't be made private. As a consequence, the client is not forced to call the static factories.
The
wither
methods are not yet generated by the compiler.
The JEP ("Java Enhancement Proposal") 468 introduces derived record creation, which consists of generating “wither” methods automatically. But this technique, as of July 2025, is not even in preview yet.
When not to use records
If we have a class with a complex business logic (many static factories, for example), regular classes are better suited than records, as they are more flexible.
Feature | Regular Class | Record |
Purpose | General-purpose, can encapsulate state and behavior | Immutable data carrier, concise data-only types |
Mutability | Can be mutable or immutable | Immutable by default (all fields are final) |
Inheritance | Can extend another class (single inheritance) and implement interfaces | Cannot extend any class; can only implement interfaces |
Constructors | Any number, including no-arg and overloaded | Canonical constructor auto-generated; can add custom constructors (with restrictions) |
Boilerplate code | Require manual implementation of getters, setters, equals , hashCode , etc. | Getters, canonical constructor, equals , hashCode , and toString auto-generated |
State-changing methods | Can have methods that mutate fields | Cannot have mutator methods for fields (fields are final) |
Field Declaration | Fields can have any visibility and can be static or final | All fields are implicitly private and final , based on record components |
Use Case | Complex logic, behaviors, mutable patterns, frameworks requiring mutability | Data transfer, value objects, DTOs, where immutability is desired |
Compatibility with JPA | Fully compatible, allows setters, no-arg constructor | Not suitable for JPA entities (no setters, no-arg constructor not provided) |
Summary
We have explained the best practices for declaring objects in Java.
Since the (not so good) old Java Beans, the principles of immutability and inheritance prohibition have been applied.
The implementation of records in Java 17 have greatly simplified the creation of objects without having to use libraries similar to Lombok.
However, in some cases it is still more convenient to use a regular class.
Subscribe to my newsletter
Read articles from José Ramón (JR) directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

José Ramón (JR)
José Ramón (JR)
Software Engineer for quite a few years. From C programmer to Java web programmer. Very interested in automated testing and functional programming.