Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 4 of 41

Lesson 4 · Object-Oriented Java

Classes, Objects and Encapsulation

Why object-oriented programming exists and how Java expresses it: classes as blueprints, objects as instances, constructors, access modifiers, static versus instance members, and encapsulation as a way to protect the rules of your data. Built around a bank account that cannot go wrong.

On this page

Key takeaways

  • A class is a blueprint that bundles data (fields) with the behaviour that works on it (methods). An object is one instance of that blueprint, and a variable of class type holds a reference to it.
  • Encapsulation means hiding the data and exposing a small set of methods that keep the object valid. The goal is to protect the rules of the data, not to hide things for their own sake.
  • Make fields private by default, validate in the constructor and methods, and do not add setters or getters that you do not need.
  • static members belong to the class, and instance members belong to each object. Mutable static state is shared by everything and is a common source of bugs.
  • A null reference points to no object, and calling a method on it throws NullPointerException. Know where null can appear and handle it deliberately.

Imagine you track bank accounts using three arrays: String[] owners, double[] balances and boolean[] frozen. To withdraw money you find the person's index in the first array, check the same index in the third, and update the second. Every piece of code that touches accounts must remember to keep the three arrays in step, and to check that the balance never goes negative. Sooner or later, one place forgets.

The idea behind object-oriented programming (OOP) is to stop scattering related data and rules across a program. Instead, you bundle the data of one thing with the code that is allowed to change it, and you make that bundle responsible for staying valid. This article shows how Java does it.

What you will learn

#
  • What problem classes and objects solve.
  • How to write a class with fields, constructors and methods.
  • What a reference to an object is, and what null means.
  • The four access levels and what encapsulation protects.
  • The difference between instance and static members.

The problem: data and rules belong together

#

A bank account is more than a number. It has an owner and a balance, and it has rules: you cannot withdraw more than you have, and you cannot deposit a negative amount. In a program with loose variables, the rules live in whoever remembers them. A better design puts the data and the rules in one place, so that nobody can create an invalid account by accident.

That is what a class does.

The mental model: blueprint and instances

#
  • A class is a blueprint. It describes what data every account has, and what every account can do.
  • An object is one concrete thing built from the blueprint: Asha's account, Ravi's account. Creating one is called instantiation, and an object is also called an instance of its class.
  • Each object has its own copy of the data, but all objects of a class share the same behaviour (the methods).

In Java, a variable whose type is a class holds a reference to an object, not the object itself, exactly as you saw with arrays in article 2. The objects live in a region of memory called the heap, which article 18 explains.

A first class

#

Here is the smallest useful version: data and behaviour in one place. First, an unprotected version, to see what goes wrong.

Java
// File: Account.javapublic class Account {    public String owner;    public double balance;}
Java
// File: Main.javapublic class Main {    public static void main(String[] args) {        Account a = new Account();        a.owner = "Asha";        a.balance = 500;        a.balance = -9999;        // nothing stops this        System.out.println(a.owner + " has " + a.balance);    }}
Code
Asha has -9999.0

new Account() created an object. The dot operator reads or writes its fields. It works, and it is dangerous: any code anywhere can put the account into a state that should be impossible. The class does not own its own rules.

Encapsulation: protect the rules

#

Encapsulation means a class keeps its data private and offers a small set of public methods through which the data can be changed, so that the class can guarantee it is always valid. Look at the improved class.

Java
// File: BankAccount.javapublic class BankAccount {    private final String owner;    private long balanceInPaise;      // money as whole paise, as discussed in article 2    public BankAccount(String owner, long openingBalanceInPaise) {        if (owner == null || owner.isBlank()) {            throw new IllegalArgumentException("owner is required");        }        if (openingBalanceInPaise < 0) {            throw new IllegalArgumentException("opening balance cannot be negative");        }        this.owner = owner;        this.balanceInPaise = openingBalanceInPaise;    }    public void deposit(long paise) {        if (paise <= 0) {            throw new IllegalArgumentException("deposit must be positive");        }        balanceInPaise += paise;    }    public void withdraw(long paise) {        if (paise <= 0) {            throw new IllegalArgumentException("withdrawal must be positive");        }        if (paise > balanceInPaise) {            throw new IllegalStateException("insufficient funds");        }        balanceInPaise -= paise;    }    public String getOwner() {        return owner;    }    public long getBalanceInPaise() {        return balanceInPaise;    }}
Java
// File: Main.javapublic class Main {    public static void main(String[] args) {        BankAccount account = new BankAccount("Asha", 50_000);        account.deposit(25_000);        account.withdraw(10_000);        System.out.println(account.getOwner() + ": " + account.getBalanceInPaise());        try {            account.withdraw(1_000_000);        } catch (IllegalStateException e) {            System.out.println("rejected: " + e.getMessage());        }        try {            new BankAccount("", 0);        } catch (IllegalArgumentException e) {            System.out.println("rejected: " + e.getMessage());        }        System.out.println("balance is still " + account.getBalanceInPaise());    }}
Code
Asha: 65000rejected: insufficient fundsrejected: owner is requiredbalance is still 65000

Look at what the design guarantees. The fields are private, so code outside the class cannot touch them. The constructor checks its inputs, so an invalid account can never be created. The only ways to change the balance are deposit and withdraw, and each enforces its rule. Whatever the rest of the program does, balanceInPaise can never become negative. That guarantee is called an invariant, and protecting invariants is the real purpose of encapsulation. (We throw exceptions for invalid input. Article 10 covers when and how.)

The anatomy of a class

#
  • Fields are variables that belong to each object (owner, balanceInPaise). Fields get default values if you do not set them: 0 for numbers, false for booleans, null for references. (Local variables do not, as you saw earlier.)
  • Constructors run when you call new. They have the same name as the class and no return type. Their job is to put the new object into a valid state. If you write no constructor, Java supplies an empty one with no parameters, but as soon as you write one yourself, the free one disappears.
  • Methods are the behaviour. Instance methods can use the object's fields directly.
  • this means "the current object". It is needed when a parameter has the same name as a field, as in this.owner = owner;, where this.owner is the field and owner is the parameter. A local name that hides a field is called shadowing.
  • final fields can be assigned once (in the constructor) and never again. owner is final because an account's owner should not change. Making things final where you can makes classes easier to reason about, and it leads to immutability, which we study in article 7.

Access modifiers

#

Modifier

Who can use the member

Typical use

private

Only code in the same class

Fields, helper methods. The default choice

(none), called package-private

Code in the same package

Internal collaboration between classes in a package

protected

Same package, and subclasses

Members meant for subclasses (article 5)

public

Everyone

The deliberate, supported interface of the class

A good rule is to start with the most restrictive modifier and widen only when you must. Every public member is a promise to the rest of the program: it is hard to change later, because other code may depend on it.

Do you need getters and setters?

#

Beginners are often taught to make every field private and then add a public getter and setter for each one. That is encapsulation in name only: a public setBalance is as dangerous as a public field. Ask what the object should do, and write methods for that (deposit, withdraw). Add a getter only for data that other code legitimately needs to read, and add a setter only when changing that value alone is a meaningful, valid operation. The habit is sometimes summed up as "tell, don't ask": tell an object what to do, instead of pulling out its data and deciding for it.

Objects and references

#

Because variables hold references, copying an object variable does not copy the object. And a reference can hold null, meaning it points to no object.

Java
// File: Main.javapublic class Main {    static class Counter {        private int count;        void increment() {            count++;        }        int get() {            return count;        }    }    public static void main(String[] args) {        Counter first = new Counter();        Counter second = first;      // a second name for the same object        first.increment();        second.increment();        System.out.println(first.get() + " " + second.get());        Counter third = new Counter();   // a different object        System.out.println(third.get());        Counter missing = null;        System.out.println(missing.get());    }}
Code
2 20Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Main$Counter.get()" because "<local4>" is null

first and second refer to one object, so both increments count towards the same total (2 2). third is a separate object with its own count. And missing.get() fails at runtime with a NullPointerException, because there is no object to call the method on. The message names the method and the variable. It says <local4>, a slot number, because by default the compiler does not record local variable names in the class file. When you compile with the debug option, which IDEs and build tools usually do, the message shows the real variable name. This kind of error is so common that article 17 is devoted to handling null well. (The static class Counter is a nested class, used here to keep the example in one file.)

static: belonging to the class, not to an object

#

Normally every object has its own copy of each field. A static member belongs to the class itself: there is exactly one copy, shared by everyone.

Java
// File: Main.javapublic class Main {    static class Ticket {        private static int nextNumber = 1;      // one copy, shared by all tickets        private final int number;        Ticket() {            this.number = nextNumber++;        }        int getNumber() {            return number;        }        static int issuedSoFar() {            return nextNumber - 1;        }    }    public static void main(String[] args) {        Ticket a = new Ticket();        Ticket b = new Ticket();        Ticket c = new Ticket();        System.out.println(a.getNumber() + " " + b.getNumber() + " " + c.getNumber());        System.out.println(Ticket.issuedSoFar() + " tickets issued");        System.out.println(Math.max(3, 8));    }}
Code
1 2 33 tickets issued8

Each ticket has its own number, but all share nextNumber, which is how every ticket got a different number. A static method is called on the class (Ticket.issuedSoFar(), Math.max(...)) and cannot use instance fields, because there is no particular object. This is why main is static: the JVM calls it before any object exists.

Use static for constants (public static final double TAX_RATE = 0.18;), for utility methods that need no object state (Math.max), and for factory methods. Be careful with mutable static fields: they are global state that any code can change, they make testing hard, and in concurrent programs they are a classic source of bugs (article 19). The Ticket counter is a teaching example. A real system would let a database generate identifiers.

Packages: organising classes

#

A package groups related classes, like folders group files, and avoids name clashes. A class declares its package on its first line: package com.example.bank;. The folder structure must match: that class lives in com/example/bank/. To use a class from another package, import it: import java.util.List;. Classes in java.lang (such as String and Math) are available without an import. Packages also provide the package-private access level, and Java convention is to use your reversed domain name as the prefix. From the next module, and in the Maven projects, we will use packages properly.

Designing a class: some questions to ask

#
  1. What is this thing, in one sentence? If you need "and", it may be two classes.
  2. What must always be true about it? Those are the invariants. Enforce them in the constructor and every method that changes state.
  3. What does it do for others? Those are the public methods, and everything else should be private.
  4. Can it be immutable? If nothing needs to change after creation, make the fields final and do not provide mutators.

Common mistakes

#
  • Public fields. Anyone can break the invariants.
  • A getter and a setter for every field. That is the same as having public fields.
  • Forgetting that new is needed. Account a; creates a variable, not an object, and its value is null until you assign something.
  • Shadowing a field with a parameter and assigning the parameter to itself (owner = owner;), which leaves the field unset.
  • Overusing static to avoid creating objects, which turns the program into a pile of global state.
  • Mutable static fields shared by everything.
  • Calling a method on null.

Exercises

#

Exercise 1

Predict

#

Using the Counter class from this article, what does Counter x = new Counter(); Counter y = new Counter(); x.increment(); System.out.println(y.get()); print, and why?

Exercise 2: Write a class

#

Write a class Rectangle with private width and height, a constructor that rejects non-positive values, and methods area() and perimeter().

Exercise 3: Find the bug

#

This constructor compiles but the owner is always null. Why? public BankAccount(String owner) { owner = owner; }

Exercise 4: Repair the encapsulation

#

A class has public int stock; and many places in the program write item.stock = item.stock - quantity;. Some of them forget to check for negative stock. Redesign the class so that negative stock is impossible, and explain what you changed.

Exercise 5: static or not

#

For each, say whether it should be static and why: (a) a method that converts Celsius to Fahrenheit, (b) the balance of an account, (c) the count of accounts created so far, (d) the interest rate that is the same for every account of a bank.

Show solutionHide solution

It prints 0. x and y are references to two different objects, each with its own count field. Incrementing x's counter does not affect y's. This is the opposite of the first/second example, where both variables referred to the same object.

Exercise 2

Show solutionHide solution
Java
public class Rectangle {    private final double width;    private final double height;    public Rectangle(double width, double height) {        if (width <= 0 || height <= 0) {            throw new IllegalArgumentException("width and height must be positive");        }        this.width = width;        this.height = height;    }    public double area() {        return width * height;    }    public double perimeter() {        return 2 * (width + height);    }}

The fields are private final, so a Rectangle is valid from the moment it exists and can never become invalid. No getters were added because the task did not need them.

Exercise 3

Show solutionHide solution

The parameter owner shadows the field owner. The assignment owner = owner; assigns the parameter to itself and never touches the field, so the field keeps its default value, null. The fix is to say which one you mean: this.owner = owner;. Some IDEs and compilers warn about a self-assignment like this, and a final field would have caused a compile error ("might not have been initialized"), which is another benefit of final.

Exercise 4

Show solutionHide solution

Make the field private and replace the scattered arithmetic with methods that own the rule:

Java
public class Item {    private int stock;    public Item(int stock) {        if (stock < 0) throw new IllegalArgumentException("stock cannot be negative");        this.stock = stock;    }    public void remove(int quantity) {        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");        if (quantity > stock) throw new IllegalStateException("not enough stock");        stock -= quantity;    }    public void add(int quantity) {        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");        stock += quantity;    }    public int getStock() {        return stock;    }}

Now the rule exists in exactly one place, so no caller can forget it, and every caller must go through a method that enforces it. The invariant "stock is never negative" holds by construction.

Exercise 5

Show solutionHide solution

(a) static: it needs no object state, just input and output, like Math.max. (b) Not static: each account has its own balance, so it must be an instance field. (c) static: it is one number shared by the whole class, the same pattern as Ticket.nextNumber. In a real system, this kind of counter would normally live in a database. (d) static (and probably final too, as a constant), if it is truly the same for every account. If different account types had different rates, it would not be.

Where to go next

#
  • Guide · Technology

    Java Essentials for DSA

  • Guide · Technology

    SQL for Beginners: Learn to Query a Database With Real Examples

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages