OOP in Java: Classes, Objects, Constructors

So far in this track you've worked with data and control flow: variables, types, and the logic that operates on them. But variables alone don't model the real world. A library doesn't have a loose String title, String author, and int copies floating around — it has books. Object-oriented programming is how Java lets you define new kinds of things that bundle data and the behavior that belongs with it.

In this post we'll build one running example — a Book class — and evolve it section by section: from a plain blueprint, through constructors and encapsulation, to packages and access control. Every snippet builds on the previous one, and every snippet compiles.

Classes vs Objects vs Instances

A class is a blueprint: it declares what fields (data) and methods (behavior) every book will have. An object is a thing built from that blueprint — a specific book with its own title and author. "Instance" is just the formal word for an object created from a class. One class, many instances.

Book (class) + title: String + author: String + copies: int + borrow(): boolean book1 (object) title = "Dune" copies = 3 book2 (object) title = "1984" copies = 0 each object holds its own field values objects live on the heap; new creates them

Here is the starting version of our example. Put it in a file named Book.java (class name and file name must match for public classes):

public class Book {
    String title;
    String author;
    int copies;

    boolean borrow() {
        if (copies > 0) {
            copies--;
            return true;
        }
        return false;
    }
}

And a small driver to use it:

public class Main {
    public static void main(String[] args) {
        Book b1 = new Book();          // 'new' creates an object on the heap
        b1.title = "Dune";
        b1.author = "Frank Herbert";
        b1.copies = 3;

        Book b2 = new Book();
        b2.title = "1984";
        b2.author = "George Orwell";
        b2.copies = 0;

        System.out.println(b1.title + " borrowed: " + b1.borrow()); // true, copies now 2
        System.out.println(b2.title + " borrowed: " + b2.borrow()); // false, copies 0
    }
}

Output:

Dune borrowed: true
1984 borrowed: false

b1 and b2 are references — variables that point to objects on the heap. They are not the objects themselves. This distinction matters: assigning b1 = b2 does not copy the book; it makes both references point at the same object. When nothing refers to an object anymore, the garbage collector reclaims its memory — you never free objects manually in Java.

Constructors: Building Objects Correctly

The snippet above has an awkward flaw: a Book exists in a half-built state between new Book() and the three field assignments. Any code could read title before it's set and get null. A constructor fixes this — it's a special method that runs exactly once, at creation time, to put the object into a valid starting state.

Constructor rules, briefly:

  • It has the same name as the class and no return type — not even void.
  • If you write no constructor at all, Java gives you a free default constructor (takes no arguments, does nothing). The moment you write any constructor, that free one disappears.
  • Unlike a method, a constructor can only be invoked via new.
public class Book {
    String title;
    String author;
    int copies;

    // Parameterized constructor: every Book is born complete
    public Book(String title, String author, int copies) {
        this.title = title;
        this.author = author;
        this.copies = copies;
    }

    boolean borrow() {
        if (copies > 0) {
            copies--;
            return true;
        }
        return false;
    }
}
Book b1 = new Book("Dune", "Frank Herbert", 3);   // valid immediately
Book b2 = new Book();                            // COMPILE ERROR: no such constructor anymore

Constructor chaining with this(...)

Often you want several ways to construct an object — a full one and a convenient shortcut. Don't duplicate the initialization logic; chain from the short constructor to the full one with this(...), which must be the first statement:

public class Book {
    String title;
    String author;
    int copies;

    public Book(String title, String author, int copies) {
        this.title = title;
        this.author = author;
        this.copies = copies;
    }

    // Convenience constructor: a new book arrives with 1 copy
    public Book(String title, String author) {
        this(title, author, 1);   // delegates to the full constructor
    }
}

Decision rule: write a parameterized constructor whenever an object is meaningless without certain values. Keep the no-arg default constructor only when a genuinely "empty" object makes sense (frameworks like JSON libraries sometimes require one — that's a framework constraint, not a design goal). And chain constructors rather than repeating assignments, so the validation logic you add later lives in exactly one place.

this: Which Variable Do You Mean?

Look at the constructor again: the parameter title and the field title have the same name. Inside the constructor, the parameter shadows the field — writing title = title; would assign the parameter to itself and leave the field null. this is a reference to the current object, and this.title unambiguously means the field:

public Book(String title, String author, int copies) {
    this.title = title;    // field = parameter
    this.author = author;
    this.copies = copies;
}

The classic trap this fixes:

public Book(String title) {
    title = title;   // BUG: does nothing — parameter assigned to itself
}

This compiles without any warning by default and produces a book whose title field stays null. Rule: whenever a parameter shares a name with a field, qualify the field with this.. (An alternative is to name parameters differently, like titleParam, but matching names with this. is the idiomatic Java style and what you'll see in every real codebase.)

this also lets an object pass itself to another method — library.register(this) — and is what this(...) builds on for constructor chaining.

Encapsulation: Guard the State

Right now, any code can do b.copies = -50; and nothing stops it. Encapsulation means the object owns its data and exposes only controlled ways to interact with it. The mechanism is simple: make fields private, expose behavior through methods.

public class Book {
    private String title;
    private String author;
    private int copies;

    public Book(String title, String author, int copies) {
        if (copies < 0) {
            throw new IllegalArgumentException("copies cannot be negative");
        }
        this.title = title;
        this.author = author;
        this.copies = copies;
    }

    public boolean borrow() { /* same as before */ }

    public int getCopies() {
        return copies;
    }
}

Now negative copies are impossible — the class guarantees its own invariant, and callers don't even have to know the rule exists. This is the real payoff: you can change the internals later (say, replace copies with a list of barcode records) without touching any code that uses Book.

The honest note about getters and setters

New Java developers often "encapsulate" like this:

private int copies;
public int getCopies() { return copies; }
public void setCopies(int copies) { this.copies = copies; }  // adds NOTHING

A setter with no logic is barely better than a public field — anyone can still set anything, you've just added a method call in the middle. Accessors earn their keep only when they add something:

  • Validation: setCopies that rejects negatives (or better, a constructor that rejects them, so the object can never be born invalid).
  • Derived state: isAvailable() returning copies > 0 — computed, not stored; no setter at all.
  • Immutability: fields declared final, set once in the constructor, getters only. If a value never changes after creation, it can't be corrupted by anyone.

Here's the evolved Book, putting these ideas together:

public class Book {
    private final String title;    // immutable identity...
    private final String author;
    private int copies;            // ...but stock changes, so it isn't final

    public Book(String title, String author, int copies) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title is required");
        }
        if (copies < 0) {
            throw new IllegalArgumentException("copies cannot be negative");
        }
        this.title = title;
        this.author = author;
        this.copies = copies;
    }

    public Book(String title, String author) {
        this(title, author, 1);
    }

    public boolean borrow() {
        if (copies > 0) {
            copies--;
            return true;
        }
        return false;
    }

    public void restock(int added) {
        if (added <= 0) {
            throw new IllegalArgumentException("added must be positive");
        }
        copies += added;
    }

    public String getTitle() { return title; }
    public String getAuthor() { return author; }
    public int getCopies() { return copies; }

    public boolean isAvailable() {   // derived state — no setter needed
        return copies > 0;
    }
}

Decision rule: expose behavior (borrow(), restock()), not raw state. Add a getter when outside code genuinely needs to read a value, a setter only when the value may legitimately change and the setter enforces the class's rules. If you can't articulate what the accessor protects or computes, it's ceremony — leave it out.

Static vs Instance Members

Everything so far has been instance state: each Book object has its own copies. Static members belong to the class itself — one shared value, no object required:

public class Book {
    // ... fields and constructors as before ...

    private static int totalBooksCreated = 0;  // shared across ALL books

    public Book(String title, String author, int copies) {
        // ... validation ...
        totalBooksCreated++;
    }

    public static int getTotalBooksCreated() {
        return totalBooksCreated;
    }
}
Book b1 = new Book("Dune", "Frank Herbert", 3);
Book b2 = new Book("1984", "George Orwell", 0);
System.out.println(Book.getTotalBooksCreated());  // 2 — called on the class, not an object

Output:

2

A static method can only touch static members — it has no this, because there is no object. Trying to read this.title inside a static method is a compile error, and it's the single most common beginner confusion here.

The warning: static is tempting as a grab-bag for shared state and "helper" methods, but mutable static state is global state. It makes methods secretly depend on things their signatures don't show, which makes them painful to test — you can't create two independent setups in one test run because they share the static field. Rule: static is right for genuinely stateless utilities (Math.max), constants (static final), and factory methods. Reach for it when the data truly belongs to the concept, not to any one object. The moment static state starts coordinating behavior between instances, pass the dependency explicitly instead.

Packages and Imports

As projects grow, classes need namespaces to avoid name collisions — Book in your library app vs. Book in some accounting library. That's what packages are:

package com.mylibrary.catalog;   // must be the FIRST line of the file

public class Book {
    // ...
}

The fully qualified name is now com.mylibrary.catalog.Book. To use it from another package, you either write the full name or import it:

import com.mylibrary.catalog.Book;   // import, then use the short name
// import com.mylibrary.catalog.*;   // wildcard: imports every class in the package

public class Main {
    public static void main(String[] args) {
        Book b = new Book("Dune", "Frank Herbert", 3);
    }
}

Two packages are always imported for you: java.lang (that's why String and System need no import) and your own current package. Practical rule: prefer explicit single-class imports over wildcards in shared codebases — when two packages both contain a Book, import com.a.* plus import com.b.* produces an ambiguous-reference compile error, while explicit imports make the conflict visible immediately. Most IDEs manage imports for you; this is worth understanding for the day you read code in a plain editor.

Access Modifiers: One Clear Picture

We've used private and public throughout; Java has four levels, and the middle two confuse everyone once. Here they are, ordered from most to least restrictive:

ModifierSame classSame packageSubclass (other package)EverywhereUse for
private✔✘✘✘Fields and helpers nobody else needs
(no modifier — package-private)✔✔✘✘Implementation shared within one package
protected✔✔✔✘Members subclasses may use or override
public✔✔✔✔The class's API: what the world may call

A few things that surprise people:

  • protected also grants package access — it's package-private plus subclass access, not subclass-only. This is the most commonly misremembered row in the table.
  • Fields with no modifier are visible to every class in the same package. Our first Book draft was effectively package-wide open — fine for a two-file example, not for a library others depend on.
  • A public class must live in a file named after it; a package-private class (class BookHelper, no modifier) can share a file with others.

Decision rule: start private and widen only when a real caller needs it. Every widening is a promise to every future reader that this member is safe to use — public is a commitment, private is freedom to change.

Trap: Uninitialized Fields vs Uninitialized Locals

Java treats these two cases differently, and the asymmetry bites everyone exactly once:

public class Book {
    private int copies;          // field: auto-initialized to 0 — compiles fine

    public void demo() {
        int shelf;               // local variable: NO default value
        System.out.println(copies);  // OK, prints 0
        System.out.println(shelf);   // COMPILE ERROR: variable shelf might not have been initialized
    }
}

Fields get defaults (0, false, null for references); local variables do not, and the compiler refuses to guess. The rationale: an uninitialized local is almost always a bug, so Java fails fast; a field's default is part of the object's contract from birth.

Don't lean on field defaults as design. Relying on copies defaulting to 0 works, but an explicit constructor argument documents intent and lets you validate. Use defaults as a safety net, not a feature.

Putting It Together

Our Book started as three public-ish fields and a method, and became a class that:

  • is always born valid (constructors + validation, with this(...) chaining),
  • owns its state (private fields; behavior methods instead of naked setters; immutable identity via final),
  • knows its scope (package, explicit imports, least-privilege access modifiers),
  • and uses static only where it belongs (shared, class-level data — with the testability caveat noted).

These are the mechanics every Java object is built from. The next post takes the biggest idea this one leaves on the table: inheritance — how classes relate to each other, when to extend and when not to, and the extends/super machinery that makes polymorphism possible.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number