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:
- It is named exactly like the class (capitalization included).
- It declares no return type. Not
void, notint, 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.
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.
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(...):
- It must be the first statement in the constructor. You cannot put anything before it, not even a
System.out.println. - No cycles allowed. If
A()callsB()andB()callsA(), 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.
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:
| Modifier | Same class | Same package | Subclass in another package | Any class |
|---|---|---|---|---|
private | Yes | No | No | No |
| (no modifier) | Yes | Yes | No | No |
protected | Yes | Yes | Yes | No |
public | Yes | Yes | Yes | Yes |
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, withArrays.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
| Mistake | What happens | How to fix it |
|---|---|---|
public void Product(...) with a return type | Java 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 constructor | The 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 constructor | The 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 reflex | Nominal 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:
- Fields
name(String) andgpa(double), bothprivate.namemust never be changeable once the object is created. - 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 to0.0and prints a notice. - A convenience constructor taking only the name and starting with a gpa of
0.0, without duplicating the validation. - A
setGpamethod that enforces the same range rule as the constructor and returnsboolean:trueif it accepts the new value,falseif it rejects it (in which case the previous gpa is kept). - A
passed()method returningtruewhen the gpa is6.0or higher. - A
mainproving, 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.
privateis the default for every field. Open only what the contract requires.- Returning an internal array or object without copying cancels encapsulation, no matter how
privatethe field is. - What cannot change cannot break: prefer
finaland immutability whenever the domain allows it.