Skip to content
C Codeloom
Java

Java Classes and Objects: OOP From Scratch

Design Java classes that hold state and behavior cleanly. Constructors, encapsulation, static vs instance, records, and the equals plus hashCode contract.

·5 min read · By Yash Kesharwani
Beginner 9 min read

What you'll learn

  • Declare fields, constructors, and methods on a class
  • Use access modifiers to encapsulate state
  • Distinguish static members from instance members
  • Implement equals, hashCode, and toString correctly
  • Replace boilerplate value classes with records

Prerequisites

A class in Java is a blueprint. It defines what an object knows (fields) and what it can do (methods). Objects are runtime instances of that blueprint. Once you grok the contract between the two, the rest of OOP is rearranging the same pieces.

A minimal class

public class Point {
    private final double x;
    private final double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double x() { return x; }
    public double y() { return y; }

    public double distanceTo(Point other) {
        double dx = x - other.x;
        double dy = y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}

Three things to notice:

  1. Fields are private and final. Outside code cannot touch them; once set, they cannot change.
  2. The constructor takes the same names and uses this.x = x to disambiguate.
  3. Accessor methods expose values without exposing the field directly.

Use it:

Point a = new Point(0, 0);
Point b = new Point(3, 4);
System.out.println(a.distanceTo(b));  // 5.0

Access modifiers

ModifierVisible to
publiceveryone
protectedsame package and subclasses
(default)same package only (no keyword)
privatethe declaring class only

Default to private for fields, public for the API you publish. Anything in between should be a deliberate decision.

Constructors

A class with no constructor gets a free default that takes no arguments. Define your own and that default disappears.

public class User {
    private final String name;
    private final int age;

    public User(String name, int age) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name required");
        }
        if (age < 0) {
            throw new IllegalArgumentException("age must be non-negative");
        }
        this.name = name;
        this.age = age;
    }

    public User(String name) {
        this(name, 0);   // delegate to the other constructor
    }
}

Validate in the constructor so an object never exists in an invalid state.

Static vs instance

Instance members belong to each object. Static members belong to the class itself.

public class Counter {
    private static int totalCreated = 0;  // shared across all instances
    private int count;                    // one per instance

    public Counter() {
        totalCreated++;
    }

    public void tick() { count++; }
    public int count() { return count; }
    public static int totalCreated() { return totalCreated; }
}

Call static members through the class name: Counter.totalCreated(). Static methods cannot access instance fields because there is no this.

this and method chaining

this refers to the current instance. Returning this from a method enables fluent chaining.

public class Query {
    private String table;
    private String where;

    public Query from(String t) { this.table = t; return this; }
    public Query where(String w) { this.where = w; return this; }

    public String build() {
        return "SELECT * FROM " + table + " WHERE " + where;
    }
}

String sql = new Query().from("users").where("age > 21").build();

toString, equals, hashCode

By default, equals compares references and toString prints a class name and a hash. Override them when an object represents a value.

import java.util.Objects;

public final class Money {
    private final long cents;
    private final String currency;

    public Money(long cents, String currency) {
        this.cents = cents;
        this.currency = Objects.requireNonNull(currency);
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Money m)) return false;
        return cents == m.cents && currency.equals(m.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(cents, currency);
    }

    @Override
    public String toString() {
        return cents + " " + currency;
    }
}

The contract: if two objects are equal, they must have the same hashCode. Breaking that contract makes HashMap and HashSet misbehave silently.

Records: stop writing boilerplate

For pure data carriers, use a record and the compiler writes the constructor, accessors, equals, hashCode, and toString for you.

public record Money(long cents, String currency) {
    public Money {
        Objects.requireNonNull(currency);
        if (cents < 0) throw new IllegalArgumentException();
    }
}

The compact constructor (public Money { ... }) validates parameters before they are assigned. Records are implicitly final and their fields are final. They are immutable by design.

Use a record when the class is “just data”. Use a regular class when you need mutable state or complex behavior.

Encapsulation in practice

A common mistake is making a field private then exposing a setter that lets anyone change it. That is no protection at all. Three better options:

  1. Make the field final and only set it in the constructor.
  2. Provide a method that performs a validated change, like withdraw(amount) rather than setBalance.
  3. Return a copy of mutable collections so callers cannot mutate your internals.
public List<String> tags() {
    return List.copyOf(tags);   // unmodifiable snapshot
}

Nested classes

A class declared inside another class is a nested class. The two forms you will actually use:

public class Outer {
    static class Helper {           // static nested: independent of Outer instance
        void run() { /* ... */ }
    }

    class Inner {                   // inner: holds an implicit reference to Outer
        void run() { /* ... */ }
    }
}

Default to static nested classes; inner classes leak a reference to the outer instance and are easy to misuse.

Wrap up

Classes hold state; constructors guard invariants; records eliminate boilerplate for plain values. Next, see how classes relate to each other in Java Inheritance and Polymorphism, then compare contracts in Java Interfaces vs Abstract Classes.