Constructors, Access Modifiers, and Getters/Setters

In the previous lesson you created objects like this:

Person p1 = new Person();
p1.name = "Laura";
p1.age = 28;

It works, but it hides a serious problem: between line 1 and line 3 there is a broken object. A Person with no name and an age of 0 is an object the class happily allows you to create even though it represents nobody. And if someone forgets line 2, that invalid object travels through the whole program until it blows up somewhere far away, where the failure no longer resembles its cause.

This lesson solves exactly that, with two tools that work together:

  • Constructors guarantee an object is born complete and valid. There is no window of time in which it exists half-assembled.
  • Encapsulation guarantees that, once born valid, nothing outside can make it invalid.

1. The constructor: the only moment an object is born

A constructor is a special block of code the JVM runs automatically during new, and only then. You recognize it by two syntactic rules:

  1. It is named exactly like the class (capitalization included).
  2. It declares no return type. Not void, not int, nothing.
public class Product {
    private String name;
    private double price;

    // Constructor: same name as the class, no return type
    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }
}

What actually happens when you run new

The constructor is not the first step of new β€” it is the fourth. Understanding the full order explains why a field can hold 0 even when your constructor assigns something else to it.

The five stages the JVM runs when evaluating the new operator Product p = new Product("Yerba", 3200); 1 Heap allocation The JVM reserves a memory block sized for every declared field. 2 Default values Every numeric field becomes 0, booleans become false and references become null. 3 Instance initializers Fields declared with a value and { } blocks, in the order they appear in the file. 4 Constructor body Only now does your code run: validate arguments, assign fields, compute derived state. 5 Reference returned The variable p, living on the Stack, now points at the finished object on the Heap. If the data is invalid, stage 4 replaces it with a safe default: the variable p always ends up pointing at a valid object.
The new operator runs five stages. The constructor is the fourth, not the first: the object already exists in memory by the time your code starts running.

The practical consequence of stage 5 is huge: if the constructor detects invalid data, it fixes it up before finishing β€” assigning a safe default and printing a notice. No object leaves stage 5 with broken data: it either holds the values you passed in, or the default values the constructor itself chose. That is the difference between validating in the constructor and validating afterwards.

Later on you will meet a more robust tool for rejecting invalid data β€” exceptions β€” in the lesson exception handling and robustness. For now we work only with what you already know: if, return values, and default values.


2. The default constructor and the trap it hides

If you write no constructor at all, the compiler hands you an empty, parameterless one:

public class Person {
    private String name;
    // The compiler implicitly inserts:
    // public Person() { }
}

Person p = new Person(); // Compiles

But the moment you write a single constructor of your own, that gift disappears:

public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }
}

Person p = new Person();          // Compilation ERROR
Person q = new Person("Laura");   // Correct

This is not a language quirk β€” it is deliberate. If you declared that a Person needs a name in order to exist, the compiler upholds that decision and refuses to let you create one without it. A constructor is a contract, and the compiler is the one that enforces it.

If you also want to keep allowing new Person(), you have to write it explicitly yourself. Don’t write it out of habit: write it only if a data-less object genuinely makes sense in your domain.


3. Constructor overloading and delegation with this(...)

A class can have several constructors as long as they differ in their parameter list (count, types, or order). That is overloading, the same concept you already saw for methods.

The classic mistake is duplicating logic across all of them:

// BAD: the price validation is copied three times.
public Product() {
    this.name = "Unnamed";
    this.price = 0;
}
public Product(String name) {
    this.name = name;
    this.price = 0;
}
public Product(String name, double price) {
    this.name = name;
    if (price < 0) {
        System.out.println("Invalid price, defaulted to 0.");
        price = 0;
    }
    this.price = price;
}

If tomorrow you add a new rule β€” the name cannot be blank β€” you have to remember to touch all three. You will forget one. It always happens.

The fix is this(...): a constructor can call another constructor of the same class and delegate the work to it. You pick one canonical constructor that concentrates the validation, and the rest simply supply default values.

Overloaded constructors delegating to a single canonical constructor Product() this("Unnamed", 0); Product(String name) this(name, 0); this(...) Product(String name, double price) CANONICAL CONSTRUCTOR Β· validates name is neither null nor blank Β· validates price is not negative Β· assigns the fields The only place where the rule lives. A new rule is added in one place and all three constructors inherit it automatically.
Delegation with this(...): convenience constructors never repeat logic β€” they just fill in defaults and call the canonical one.
public class Product {
    private String name;
    private double price;

    public Product() {
        this("Unnamed", 0);             // delegates
    }

    public Product(String name) {
        this(name, 0);                  // delegates
    }

    // Canonical constructor: ALL validation lives here
    public Product(String name, double price) {
        if (!setName(name)) {
            this.name = "Unnamed";
            System.out.println("Invalid name, defaulted to \"Unnamed\".");
        }
        if (!setPrice(price)) {
            this.price = 0;
            System.out.println("Invalid price, defaulted to 0.");
        }
    }

    // Validating setter: true if it accepts the value, false if it rejects it (and leaves the field untouched)
    public boolean setName(String name) {
        if (name == null || name.isBlank()) {
            return false;
        }
        this.name = name;
        return true;
    }

    public boolean setPrice(double price) {
        if (price < 0) {
            return false;
        }
        this.price = price;
        return true;
    }
}

Two rules the compiler will enforce around this(...):

  1. It must be the first statement in the constructor. You cannot put anything before it, not even a System.out.println.
  2. No cycles allowed. If A() calls B() and B() calls A(), that is a compile error, not a runtime stack overflow.

4. Encapsulation: the problem it actually solves

Encapsulation is not β€œmake everything private and let the IDE generate getters and setters.” That is ritual, not design. Encapsulation is this:

The object owns its own state and is solely responsible for keeping it consistent.

As long as a field is public, any line in any file of the project can leave it in an impossible state, and there is no way to prevent it or to find out who did it.

Comparison between a class with public fields and an encapsulated class Unencapsulated β€” public fields External code p.price = -500; direct access public double price; no defense at all price = -500 impossible state The object is inconsistent and nobody could stop it. The error will surface at billing time, three layers up, where no trace is left of what caused it. Possible culprits: the entire project. Encapsulated β€” private fields External code p.setPrice(-500); the only door setPrice() if (price < 0) return false; price untouched rejected upfront The invalid assignment is rejected before it reaches the field. The error is detected on the exact line that caused it: the setter returns false right there, without touching the price. Possible culprits: one.
The real difference is not stylistic β€” it is where the error is detected. Encapsulation turns a diffuse bug into an exception with an exact address.

Look closely at the last line of each panel, because that is the whole point. With public fields, when you find a negative price in production you have to audit the entire project. With a validating setter, the rejection happens on the exact line that caused it: setPrice returns false, the field stays untouched, and whoever called the method knows right away that something went wrong.


5. The four access modifiers

Java defines four visibility levels, from most open to most closed. Think of them as concentric circles of trust:

Java's four visibility levels as concentric circles public β€” any class in any package, including another project entirely protected β€” same package, plus subclasses even if they live in another package no modifier (package-private) β€” only classes in the same package private β€” inside the declaring class only Not even subclasses can see it. This is your default for every field. Always. Start closed and open only what other code genuinely needs: closing what you already published breaks every one of your users.
The further inward, the less code can touch it β€” and the less surface you have to audit when something goes wrong.
ModifierSame classSame packageSubclass in another packageAny class
privateYesNoNoNo
(no modifier)YesYesNoNo
protectedYesYesYesNo
publicYesYesYesYes

The working rule: fields are always private; methods are public only if they are part of the contract the class offers the world. Everything else stays as closed as possible. Widening visibility later is trivial; narrowing it breaks all the code that already depended on it.

(Conceptual note: in Java, packages function as namespaces as well as encapsulation boundaries. We explore formal namespace theory, collision resolution, and package architecture in depth in the Abstract Classes, Interfaces, and Code Organization lesson).


6. Getters and setters done right

An auto-generated getter/setter pair that does nothing but read and write the field is, in practice, a public field with extra ceremony:

// This encapsulates NOTHING. It is a public field in disguise.
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }

Accessors earn their keep when they do something: validate, transform, compute β€” or simply do not exist.

public class BankAccount {
    private final String owner;   // final: never changes after the constructor
    private double balance;

    public BankAccount(String owner, double initialBalance) {
        if (owner == null || owner.isBlank()) {
            System.out.println("Invalid owner, defaulted to \"Unknown owner\".");
            owner = "Unknown owner";
        }
        if (initialBalance < 0) {
            System.out.println("Invalid initial balance, defaulted to 0.");
            initialBalance = 0;
        }
        this.owner = owner;
        this.balance = initialBalance;
    }

    // Getter: yes. Reading the balance is part of the public contract.
    public double getBalance() { return balance; }

    // Balance setter: NO. Nobody should be able to write the balance directly.
    // Instead, domain operations that express intent and return a boolean:
    // true if applied, false if rejected.
    public boolean deposit(double amount) {
        if (amount <= 0) {
            return false;
        }
        this.balance += amount;
        return true;
    }

    public boolean withdraw(double amount) {
        if (amount <= 0 || amount > balance) {
            return false;
        }
        this.balance -= amount;
        return true;
    }
}

Compare the two ways of writing the same thing:

account.setBalance(account.getBalance() - 5000);   // What is going on here? Was anything validated?
account.withdraw(5000);                            // Intent is explicit and the rule is enforced.

The second version is not just more readable: it is the only one of the two in which the insufficient-funds rule can exist at all. Methods should name domain operations, not data movements.


7. Reference leaking: the bug that breaks encapsulation without you noticing

This is where most β€œencapsulated” classes fall apart. Look:

public class Course {
    private static final int MAX_CAPACITY = 30;
    private final String[] students = new String[MAX_CAPACITY];
    private int count = 0;

    public String[] getStudents() {
        return students;   // ⚠️ We are handing out the internal reference
    }
}

Everything looks right: the field is private, there is a getter. But:

Course c = new Course();
c.getStudents()[0] = "Intruder";               // We mutate internal state from outside
java.util.Arrays.fill(c.getStudents(), null);  // And wipe it entirely

The getter handed over the memory address of the internal array, not a copy. Whoever receives it has full control. The private bought you nothing, because private protects the field, not the object it points to.

There are two fixes, from least to most rigid:

// 1. Defensive copy: the caller gets its own independent array.
public String[] getStudents() {
    String[] copy = new String[count];
    for (int i = 0; i < count; i++) {
        copy[i] = students[i];
    }
    return copy;
}

// 2. Do not expose the collection: expose only the operations that make sense.
public boolean enroll(String student) {
    if (student == null || student.isBlank() || count == students.length) {
        return false;
    }
    students[count] = student;
    count++;
    return true;
}

public int enrolledCount() { return count; }

The second option is almost always the best one, and not for tidiness: it is the only one that later lets you add an extra business rule (for example, rejecting duplicate names) without changing the class’s public signature.

The same trap applies to constructors: if you receive an array as a parameter and assign it directly with this.data = data, whoever passed it keeps a live reference to your internal state. Copy it on the way in too (for example, with Arrays.copyOf, which you already know from the arrays lesson).


8. Immutability: encapsulation taken to its limit

An immutable object never changes after birth. Because it never changes, it cannot become inconsistent, it needs no setters, and it is safe to share across threads with no synchronization at all.

public final class Coordinate {          // final: nobody can subclass and break the rules
    private final double latitude;       // final: assigned only in the constructor
    private final double longitude;

    public Coordinate(double latitude, double longitude) {
        if (latitude < -90 || latitude > 90) {
            System.out.println("Latitude out of range, defaulted to 0.0.");
            latitude = 0;
        }
        if (longitude < -180 || longitude > 180) {
            System.out.println("Longitude out of range, defaulted to 0.0.");
            longitude = 0;
        }
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public double getLatitude() { return latitude; }
    public double getLongitude() { return longitude; }

    // To "modify", return a new instance
    public Coordinate shift(double dLat, double dLon) {
        return new Coordinate(latitude + dLat, longitude + dLon);
    }
}

Since Java 16 there is a shorthand for this kind of data carrier, the record, which generates the constructor, accessors, equals, hashCode, and toString for you:

public record Coordinate(double latitude, double longitude) {
    // Compact constructor: you only write the validation
    public Coordinate {
        if (latitude < -90 || latitude > 90) {
            System.out.println("Latitude out of range, defaulted to 0.0.");
            latitude = 0;
        }
    }
}

You will see this a lot in modern code. For now, keep the underlying idea: the less an object can change, the fewer ways there are to break it.


9. Common mistakes

MistakeWhat happensHow to fix it
public void Product(...) with a return typeJava compiles it as an ordinary method named Product, not a constructor. The object is never initialized and you get no warning.Delete the return type.
Assigning without this under shadowing: name = name;The parameter assigns to itself. The field stays null. Compiles cleanly.Use this.name = name;.
Calling an overridable method from the constructorThe subclass runs that method before its own fields are initialized.Have the constructor call only private or final methods.
Validating in the setter but not in the constructorThe object can be born invalid and is only protected afterwards.Have the constructor delegate to the setter, or both delegate to a private validator.
Generating getters and setters for every field by reflexNominal encapsulation: the state is as exposed as if it were public.Expose only what the contract genuinely requires.

10. Guided hands-on exercise

Challenge: the Student class

Write a Student class that satisfies all of these conditions:

  1. Fields name (String) and gpa (double), both private. name must never be changeable once the object is created.
  2. A canonical constructor taking name and gpa: if the name is null or blank, it defaults to "Unnamed" and prints a notice; the gpa is validated by delegating to the setter and, if invalid, defaults to 0.0 and prints a notice.
  3. A convenience constructor taking only the name and starting with a gpa of 0.0, without duplicating the validation.
  4. A setGpa method that enforces the same range rule as the constructor and returns boolean: true if it accepts the new value, false if it rejects it (in which case the previous gpa is kept).
  5. A passed() method returning true when the gpa is 6.0 or higher.
  6. A main proving, through the returned booleans and the printed notices, that the object never ends up with an out-of-range gpa, either at construction time or at modification time.
See suggested solution
public class Student {
    private final String name;   // final: fixed by the constructor, never changes
    private double gpa;

    // Convenience constructor: delegates, does not duplicate
    public Student(String name) {
        this(name, 0.0);
    }

    // Canonical constructor
    public Student(String name, double gpa) {
        if (name == null || name.isBlank()) {
            System.out.println("Invalid name, defaulted to \"Unnamed\".");
            name = "Unnamed";
        }
        this.name = name;
        if (!setGpa(gpa)) {
            this.gpa = 0.0;
            System.out.println("Invalid gpa, defaulted to 0.0.");
        }
    }

    public String getName() {
        return name;
    }

    public double getGpa() {
        return gpa;
    }

    // true if it accepts the new gpa, false if it rejects it (and keeps the previous one)
    public boolean setGpa(double gpa) {
        if (gpa < 0.0 || gpa > 10.0) {
            return false;
        }
        this.gpa = gpa;
        return true;
    }

    public boolean passed() {
        return gpa >= 6.0;
    }

    @Override
    public String toString() {
        return name + " β€” gpa " + gpa + (passed() ? " (passed)" : " (failed)");
    }

    public static void main(String[] args) {
        Student s1 = new Student("Laura Gimenez", 8.4);
        System.out.println(s1);              // Laura Gimenez β€” gpa 8.4 (passed)

        Student s2 = new Student("Carlos Ruiz");
        System.out.println(s2);              // Carlos Ruiz β€” gpa 0.0 (failed)

        boolean accepted = s2.setGpa(7.2);
        System.out.println("Was 7.2 accepted? " + accepted);
        System.out.println(s2);              // Carlos Ruiz β€” gpa 7.2 (passed)

        // The object defends itself on modification: the invalid value is
        // rejected and the previous gpa is left untouched.
        boolean rejected = s2.setGpa(15.0);
        System.out.println("Was 15.0 accepted? " + rejected);
        System.out.println(s2);              // still 7.2, unchanged

        // And on construction: it never ends up with an invalid gpa
        Student invalid = new Student("", 5.0);
        System.out.println(invalid);         // Unnamed β€” gpa 5.0 (failed)
    }
}

What matters about this solution is not that it compiles, but that the 0.0–10.0 range rule is written exactly once. The canonical constructor calls setGpa, and the convenience constructor calls the canonical one. If the range becomes 1.0–10.0 tomorrow, you change one line and all three paths are fixed.


Key takeaways

  • The constructor is the only guarantee that an object is born valid: if the data is invalid, it substitutes a safe default before finishing, so an object with broken data never comes to exist.
  • Writing a constructor removes the one the compiler used to give you. That is a feature, not a bug.
  • this(...) concentrates validation in a canonical constructor and stops rules from being duplicated.
  • Encapsulation means making the object responsible for its own consistency, not mass-generating accessors.
  • private is the default for every field. Open only what the contract requires.
  • Returning an internal array or object without copying cancels encapsulation, no matter how private the field is.
  • What cannot change cannot break: prefer final and immutability whenever the domain allows it.