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.

FeatureRegular ClassRecord
PurposeGeneral-purpose, can encapsulate state and behaviorImmutable data carrier, concise data-only types
MutabilityCan be mutable or immutableImmutable by default (all fields are final)
InheritanceCan extend another class (single inheritance) and implement interfacesCannot extend any class; can only implement interfaces
ConstructorsAny number, including no-arg and overloadedCanonical constructor auto-generated; can add custom constructors (with restrictions)
Boilerplate codeRequire manual implementation of getters, setters, equals, hashCode, etc.Getters, canonical constructor, equals, hashCode, and toString auto-generated
State-changing methodsCan have methods that mutate fieldsCannot have mutator methods for fields (fields are final)
Field DeclarationFields can have any visibility and can be static or finalAll fields are implicitly private and final, based on record components
Use CaseComplex logic, behaviors, mutable patterns, frameworks requiring mutabilityData transfer, value objects, DTOs, where immutability is desired
Compatibility with JPAFully compatible, allows setters, no-arg constructorNot 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.

0
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.