Classes, constructors and invariants

A constructor's real job is to make an object that is never wrong. Validation, final fields, the default constructor that disappears, JavaBeans, and the builder that arrives when there are too many parameters.

13 min read🧱 Object-Oriented Java

A class is not a bag of fields with getters and setters. It is a type that maintains invariants: facts about its state that are true after construction and stay true after every method call. An Order whose total never disagrees with its lines; a DateRange whose end is never before its start; a Money whose currency is never null. The constructor's job is to establish those facts. Everything else is about not breaking them. This lesson is that job, what new actually does in the JVM, the one way a constructor can leak a broken object, and the framework that quietly bypasses every constructor you write.

The constructor's real job

java
public final class DateRange {
    private final LocalDate start;
    private final LocalDate end;
 
    public DateRange(LocalDate start, LocalDate end) {
        this.start = Objects.requireNonNull(start, "start");
        this.end = Objects.requireNonNull(end, "end");
        if (end.isBefore(start)) {
            throw new IllegalArgumentException("end " + end + " is before start " + start);
        }
    }
 
    public boolean contains(LocalDate d) {
        return !d.isBefore(start) && !d.isAfter(end);
    }
}

After this constructor returns, a DateRange is never wrong. contains does not check for null fields or an inverted range, because it cannot happen — the constructor refused. Every method in the class gets simpler, and every caller gets a guarantee: if you hold a DateRange, it is valid.

Compare the alternative: a no-arg constructor, two setters, and a validate() method someone must remember to call. Now every method must defend against a half-built object, and the bug appears wherever someone forgot.

Under the hood: what new does

new DateRange(a, b) compiles to four bytecodes, and the order explains two rules you have been told without reasons:

javap -c, for new DateRange(a, b)plaintext
new           #7   // class DateRange     → allocate: header + fields zeroed (null, 0, false)
dup                                       → keep a copy of the reference for after <init>
aload_1; aload_2                          → the arguments
invokespecial #9   // DateRange.<init>    → run the constructor on the zeroed object

Allocation and construction are separate steps. After new, the object exists with every field at its default; <init> is an ordinary method (invokespecial, no dynamic dispatch) that fills it in. That is why a field you never assign is null rather than garbage, and why a constructor that throws leaves nothing behind: the reference on the stack is dropped and the zeroed object is garbage.

<init> is assembled by javac from three sources, in this order: the super(...) call (or an implicit super()), then every field initialiser and instance initialiser block in textual order, then the constructor body. Field initialisers are not "run before the constructor" by magic; javac copies them into the start of every constructor that does not delegate with this(...), which is why a class with five constructors and a private final List<X> items = new ArrayList<>() has that allocation compiled five times.

The JMM's final field guarantee attaches to the end of <init>: writes to final fields are frozen when the constructor returns, so any thread that later obtains the reference sees them. The guarantee has one condition, and it is the next section.

this must not escape

A constructor that publishes a half-built objectjava
public final class PriceWatcher {
    private final Set<Sku> watched;
    public PriceWatcher(EventBus bus, Collection<Sku> skus) {
        bus.subscribe(this);                 // `this` escapes: another thread can call onPrice() NOW
        this.watched = Set.copyOf(skus);     // ...before this line has run
    }
    public void onPrice(PriceEvent e) { if (watched.contains(e.sku())) alert(e); }   // NullPointerException
}

Handing this to anything during construction, a listener, a thread, a static registry, a lambda that captures it, gives the outside world a reference to an object whose <init> has not finished. The final guarantee does not apply to a reference obtained that way, and on a multi-core machine watched can be seen as null even after the assignment ran. The same applies to calling an overridable method from the constructor (the Inheritance lesson): the subclass's override runs on an object whose subclass fields are still zero. Fix: finish constructing, then register, from a static factory that does both.

Final fields

final on a field means it is assigned exactly once, in the constructor or at declaration. It does three things:

  1. Documents that the field does not change.
  2. Makes the compiler check that every constructor assigns it (definite assignment analysis, JLS chapter 16).
  3. Gives a memory-model guarantee: a properly constructed object's final fields are visible, with their constructor-assigned values, to every thread that sees the object — without synchronisation. Non-final fields have no such guarantee.

Make every field final that can be. When you find one that cannot, ask why the object needs to change — often it does not, and a new object is the better design.

The default constructor, and when it disappears

A class that declares no constructor still has one. javac adds a default constructor with no parameters, the same access as the class, and a body that only calls super(). javap shows it: public class P {} compiles to a class with public P();, and a package-private class Q {} gets Q();.

Declare any constructor at all, and the default one is not added. That single rule is behind two errors most people meet in their first week:

java
class Money {
    Money(long minorUnits) { ... }
}
new Money();   // constructor Money in class Money cannot be applied to given types
 
class Base {
    Base(int id) { ... }
}
class Derived extends Base { }   // the same error: Derived's default constructor calls super(), and there is no Base()

A constructor that takes arguments is often called a parameterised constructor. A class can declare several, as long as their parameter lists differ, which is constructor overloading. If a class needs a no-argument constructor as well as one with parameters, it must declare both.

Constructor chaining

java
public Money(long minorUnits, Currency currency) { ... }          // the canonical one
public Money(long minorUnits) { this(minorUnits, Currency.EUR); } // delegates

One constructor does the work; the others call it with this(...). Validation lives in one place. this(...) or super(...) had to be the first statement for twenty-eight years; flexible constructor bodies (JEP 513, final in Java 25) allow statements before it, as long as they do not read or write this, so an argument can be validated or transformed before being handed up: if (units < 0) throw ...; super(units);.

Walkthrough: the entity the constructor never saw

A JPA entity with the DateRange idea, and a production NullPointerException in contains() that "cannot happen":

Booking.javajava
@Entity
public class Booking {
    @Id private Long id;
    private LocalDate start, end;
    public Booking(LocalDate start, LocalDate end) { /* validates as above */ }
    protected Booking() {}                                  // required by JPA
    public boolean contains(LocalDate d) { return !d.isBefore(start) && !d.isAfter(end); }
}
  1. Hibernate loads a row by calling the no-arg constructor (through reflection, or, with bytecode enhancement, by allocating the object without running any constructor at all) and then writing the fields directly. Your validating constructor is never involved for a loaded entity.
  2. A migration script inserted a booking with end_date NULL. No constructor refused it; the database did not either, because the column was nullable.
  3. contains() dereferences end: NPE, from an object that "cannot be wrong".
  4. The invariant has to live in three places for an entity: the constructor (for new objects), a NOT NULL and a CHECK (end_date >= start_date) constraint (for every row, whoever writes it), and a @PostLoad check if you want the JVM to refuse a bad row rather than fail later.

The general lesson: a constructor guards the objects it creates. Deserialisers (Jackson, JPA, Java serialisation, Kryo) create objects by other routes, and an invariant that matters must also be enforced at those routes or at the data. Records are the honest exception: Java serialisation of a record goes through the canonical constructor, and Jackson uses it too, so a record's compact-constructor validation runs on every path.

Too many parameters: the builder

A constructor with seven parameters, four of them optional, three of them String, is unreadable at the call site and easy to mis-order. The builder trades that for named, ordered-any-way steps, and still validates once at the end:

java
Order order = Order.builder()
        .customer(customerId)
        .line(sku, 2)
        .line(otherSku, 1)
        .shipping(Address.parse(raw))
        .note("leave at door")
        .build();                       // validates everything here
java
public static final class Builder {
    private CustomerId customer;
    private final List<Line> lines = new ArrayList<>();
    private Address shipping;
    private String note = "";
 
    public Builder customer(CustomerId c) { this.customer = c; return this; }
    public Builder line(Sku sku, int qty) { lines.add(new Line(sku, qty)); return this; }
    // ...
    public Order build() {
        Objects.requireNonNull(customer, "customer");
        if (lines.isEmpty()) throw new IllegalStateException("an order needs at least one line");
        return new Order(this);       // Order's constructor copies the list
    }
}

The builder is mutable and temporary; the built object is immutable and validated. Lombok's @Builder generates this shape; write the build() validation yourself, because Lombok will not, and know that @Builder on a class with a List field gives you null, not an empty list, unless you add @Singular or a default.

Do not reach for a builder with three parameters. Do reach for it when parameters are optional, when several share a type, or when construction happens in steps.

Getters and setters are not automatic

A getter exposes state; a setter lets outsiders change it. Neither is free. A class with a setter for every field has no invariants, because any field can be changed to anything at any time. Ask, for each field:

  • Does anyone outside need to read it? If not, no getter.
  • Does anyone outside need to change it independently of the others? Almost never. Model the change as an operation with a name: order.cancel(), not order.setStatus(CANCELLED), because cancelling also records a reason and a time and checks the order was not already shipped.

A record gives you accessors for free and no setters at all — which is the right default for data.

JavaBeans, the convention frameworks expect

JavaBeans is a naming convention from the late 1990s, not a language feature, and much of the Java ecosystem still reads classes through it. A class is a bean when it has:

  • a public no-argument constructor;
  • private fields exposed as properties through accessor methods: getName() and setName(String), or isActive() for a primitive boolean;
  • optionally, implements Serializable.

A property is named after its methods, not its field: getName is the property name, whatever the field is called. Two details of the naming rules catch people. getURL() is the property URL, not uRL, because a name that starts with two capital letters is left alone. And the is prefix counts only for a primitive boolean: java.beans.Introspector does not treat Boolean isEnabled() as a getter, so it reports a property enabled that can be written and never read.

You meet the convention wherever a framework reads or builds objects by reflection. Jackson treats every getter as a JSON property. JPA needs a no-argument constructor, although protected is enough. Spring binds form data and configuration through setters, and template expression languages read ${order.total} by calling getTotal().

The convention works against everything above. A bean is created empty and filled in by setters, so for part of its life it is invalid, and any caller can change any property at any time. Use beans where a framework demands them, at the edges of the system — a request body, a form, a configuration class — and turn them into objects with invariants as soon as they cross into your own code. Records are removing much of the need: Jackson (since 2.12) and Spring Boot's configuration binding can both build a record through its canonical constructor.

Try it yourself

What prints?

java
class Config {
    private final String name;
    private final int retries = compute();
    Config(String name) { this.name = name; }
    private int compute() { System.out.println("name=" + name); return 3; }
}
new Config("api");
Answer

name=null. Field initialisers run at the top of the constructor, before the body, in textual order; retries = compute() executes while name is still at its zeroed default. The object was allocated with every field null or zero, and <init> fills them in order. Move the computation into the constructor body after this.name = name, or make compute not depend on name.

Find the escape

java
class Cache {
    private final Map<String, String> map = new ConcurrentHashMap<>();
    private final ScheduledFuture<?> sweeper;
    Cache(ScheduledExecutorService ses) {
        sweeper = ses.scheduleAtFixedRate(this::sweep, 0, 1, TimeUnit.MINUTES);
    }
    void sweep() { map.entrySet().removeIf(e -> stale(e)); }
}

Is there a problem, and what is the smallest fix?

Answer

this::sweep captures this and hands it to another thread before the constructor returns; with an initial delay of 0, sweep() can run on the scheduler thread while <init> is still executing. Here map was assigned by a field initialiser before the schedule line, so it usually works, but the final guarantee does not cover a reference that escaped, and a reordering could expose map as null. Fix: a static factory that constructs the Cache, then schedules the sweep on the finished object.

Where does the invariant live?

An Invoice must always have total == sum(lines). It is created in code, loaded by JPA, and deserialised from Kafka by Jackson. List where the invariant must be enforced for it to be true everywhere.

Answer

Three places at least. The constructor or factory, for objects code creates. The Kafka path: a Jackson @JsonCreator constructor (or a record) that recomputes and checks, since Jackson otherwise uses setters or field access. The JPA path: a database CHECK constraint cannot express a sum across tables, so a @PostLoad verification or, better, not storing total at all and computing it from the lines. An invariant that is stored redundantly must be checked wherever the redundancy can be written.

Misconceptions

  • "A class with no constructor has no constructor." It has the default one javac adds, and it loses it the moment you declare any other.
  • "JavaBeans are a special kind of class." They are a naming convention. Nothing in the language checks it; frameworks find the methods by their names, through reflection.
  • "Field initialisers run before the constructor." They run inside every constructor, after super() and before the body, copied there by javac. Order within the constructor is textual order.
  • "A final field is safe once assigned." Only if this did not escape during construction. The guarantee is about the end of <init>, and a leaked reference bypasses it.
  • "The constructor guarantees the invariant." For objects the constructor built. JPA, Jackson and serialisation build objects by other routes; the invariant must be enforced there or at the data.
  • "Getters and setters are encapsulation." They are the mechanism. Encapsulation is that the object cannot be observed invalid; a setter per field is the opposite.
  • "super() must be the first statement." It was. Java 25 allows statements before it that do not touch this, which is exactly where argument validation wants to go.

Going deeper

  • JVMS §2.9.1 (instance initialisation methods) and JLS §12.5 (creation of new class instances), which spells out the order.
  • JLS §17.5, final field semantics, and the this-escape condition in the last paragraph of §17.5.1.
  • JEP 513, Flexible Constructor Bodies (Java 25).
  • Effective Java, items 1 (static factories), 2 (builders), 17 (minimise mutability).
  • Hibernate's documentation on entity instantiation and bytecode enhancement, for what a loaded entity's constructor sees: nothing.
Progress is saved on this device and to your account when signed in.