Classes and objects: the building blocks of OOP
Understand classes as blueprints and objects as instances: fields, constructors, methods, access modifiers, and Java records for value objects.
Introduction
Most object-oriented programs start with two concepts: a class that describes what something is, and an object that is that something at runtime. Classes give state and behavior a home; objects hold the state for one concrete instance. These concepts are the foundation for encapsulation, inheritance, polymorphism, and object collaboration.
This article uses a BankAccount to connect the language-level ideas to practical class design: valid construction, controlled mutation, object lifecycle, and the choice between records and regular classes.
TL;DR / Mental Model
- Mental model: a class is the recipe; an object is one made instance with its own instance-field values.
- Boundary: a reference variable points to an object; it is not the object itself. Multiple references can point to the same object, while
staticstate belongs to the class rather than one instance. - Good class design: construct a valid object, keep state private, expose behavior through meaningful methods, and keep the public API small.
- Trade-off: more encapsulation protects invariants but can require deliberate methods or builders; records reduce boilerplate when value-based, shallowly immutable data is the goal.
30-Second Explanation
A class defines fields and methods. An object is an instance created from that definition, with its own values for instance fields. A constructor establishes valid initial state, methods enforce the object's rules, and access modifiers decide what other code can see. Use a record for compact value-oriented data; use a regular class when identity, mutable state, or richer lifecycle behavior matters.
5-Minute Explanation
Design a class around the state it owns and the invariants it must protect. Put initialization in a constructor or builder, keep mutable fields private, expose commands for valid changes and queries for reading, and return controlled views of collections. Then decide whether the type is an entity with identity or a value with field-based equality. The detailed BankAccount example below shows this lifecycle and how a record can represent its transaction data.
What Are Classes and Objects
A class is a type definition. It declares the fields an entity holds and the methods it can perform; the JVM loads metadata for the class, while each object holds its own values for instance fields. In the usual Java mental model, objects are allocated on the heap, although the JVM may optimize an allocation when it can prove that the object does not need to escape.
Think of it like an architectural floor plan for an apartment. The plan specifies "two bedrooms, one kitchen, a balcony facing east." But nobody lives inside a floor plan. You build concrete apartments from it, each with its own furniture, residents, and quirks. The plan is the class; each apartment is an object.
// The class (blueprint): describes shape + behavior
public class BankAccount {
private String id;
private double balance;
public void deposit(double amount) { balance += amount; }
}
// The objects (instances): each lives independently on the heap
BankAccount alice = new BankAccount("A-001", 500.0);
BankAccount bob = new BankAccount("B-042", 0.0);
alice.deposit(200.0); // alice.balance is now 700.0
// bob.balance is still 0.0
alice and bob share the same method behavior but own separate instance state. Modifying one does not touch the other because the fields in this example are instance fields. That independence is the core idea.
One class, many objects. Each object carries its own field values. Methods are shared, but the this reference inside each method points to the object that called it.
Anatomy of a Class
Every Java class is built from four building blocks: fields, constructors, methods, and access modifiers. Here is how they fit together.
Fields (instance variables)
Fields hold the state that makes each object unique. Mark them private by default. If outside code needs to read a value, add a getter. If outside code needs to change it, think twice: a command method that validates the change is often better than a raw setter.
private final String id; // immutable after construction
private double balance; // mutable, but only through commands
private final List<Transaction> history = new ArrayList<>();
Make fields final whenever possible. If a field's value is set once in the constructor and never changed, final communicates that to readers and catches accidental reassignment at compile time.
Constructors
A constructor initializes the object into a valid state. Avoid leaving an object half-built. If a BankAccount requires an ID and an opening balance, demand both in the constructor.
public BankAccount(String id, double initialBalance) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Account ID is required");
}
if (initialBalance < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.id = id;
this.balance = initialBalance;
}
The rule of thumb: after new BankAccount(...) returns, the object must be usable with no further setup calls. If you find yourself writing account.setId(...) after construction, the constructor is incomplete.
Methods
Methods define what the object can do. Split them into two categories:
| Type | Purpose | Examples |
|---|---|---|
| Commands | Mutate state, enforce invariants | deposit(), withdraw(), freeze() |
| Queries | Return information, never mutate | getBalance(), isFrozen(), getHistory() |
Keep commands and queries separate. A method that both changes state and returns a value is harder to reason about and harder to test.
Access Modifiers
Java gives you four levels of visibility. Default to the most restrictive and widen only when you have a reason.
| Modifier | Visible to | Use when |
|---|---|---|
private | Same class only | Fields, internal helpers |
| package-private (no keyword) | Same package | Collaborating classes in the same module |
protected | Same package + subclasses | Framework extension points (rare in application code) |
public | Everyone | The class's contract: what callers depend on |
A compact rule is: start private, widen only with a reason. It keeps the public API focused and makes the intended contract easier to see.
Object Lifecycle
Every object goes through three phases: creation, usage, and cleanup. Understanding this lifecycle helps you avoid common bugs like using uninitialized objects or leaking resources.
Creation happens in two steps: the JVM allocates heap memory, then the constructor runs. By the time new returns, the object is fully initialized or the constructor threw an exception. There is no "partially created" state.
Usage is the main phase. The object receives method calls, its fields change, and it participates in the program's logic. This is where encapsulation matters most: the object's methods are the gatekeepers of its state.
Garbage collection happens automatically. When no live reference can reach an object, the garbage collector can reclaim its memory. You do not call free() or delete. The timing is non-deterministic, which is why critical cleanup should not rely on finalize(). Use try-with-resources for closable resources like file handles and database connections.
Stack vs heap: a useful model
In the usual Java mental model, local variables and references live in stack frames, while the objects they point to live on the heap. When you write BankAccount alice = new BankAccount(...), alice is a reference and the actual BankAccount data is the object. When alice goes out of scope, the reference vanishes, but the object may persist until no other references reach it and the JVM reclaims it. JIT escape analysis can optimize these details, so treat stack versus heap as a teaching model rather than an absolute language guarantee.
Implementation
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public class BankAccount {
private final String id;
private double balance;
private boolean frozen;
private final List<Transaction> history = new ArrayList<>();
public BankAccount(String id, double initialBalance) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Account ID is required");
}
if (initialBalance < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.id = id;
this.balance = initialBalance;
record(TransactionType.OPENING, initialBalance);
}
public void deposit(double amount) {
requireNotFrozen();
if (amount <= 0) {
throw new IllegalArgumentException("Deposit must be positive: " + amount);
}
balance += amount;
record(TransactionType.DEPOSIT, amount);
}
public void withdraw(double amount) {
requireNotFrozen();
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal must be positive: " + amount);
}
if (amount > balance) {
throw new IllegalStateException("Insufficient funds");
}
balance -= amount;
record(TransactionType.WITHDRAWAL, amount);
}
public void freeze() { this.frozen = true; }
public void unfreeze() { this.frozen = false; }
public String getId() { return id; }
public double getBalance() { return balance; }
public boolean isFrozen() { return frozen; }
public List<Transaction> getTransactionHistory() {
return Collections.unmodifiableList(history);
}
private void requireNotFrozen() {
if (frozen) {
throw new IllegalStateException("Account " + id + " is frozen");
}
}
private void record(TransactionType type, double amount) {
history.add(new Transaction(
UUID.randomUUID().toString(), type, amount, Instant.now(), balance
));
}
@Override
public String toString() {
return "BankAccount{id='%s', balance=%.2f, frozen=%s}".formatted(id, balance, frozen);
}
}Walk through the Main class to see the core idea in action. alice and bob are independent objects built from the same BankAccount class. Depositing into alice does not change bob. Freezing alice does not freeze bob. Each object is its own island of state.
Records vs Classes
Java 14 introduced records (stable since Java 16) as a compact way to declare classes whose purpose is to carry data. A record gives you equals, hashCode, toString, and accessor methods for free.
// 1 line of record replaces ~40 lines of boilerplate class
public record Money(double amount, String currency) {}
// Usage
Money price = new Money(29.99, "USD");
System.out.println(price.amount()); // 29.99
System.out.println(price); // Money[amount=29.99, currency=USD]
The key difference: records are immutable. Once created, their fields cannot change. This makes them perfect for value objects, DTOs, and event payloads. Regular classes are for entities with mutable state and behavior.
| Use a record when... | Use a class when... |
|---|---|
| The object is just data (DTO, value object, event) | The object has mutable state |
Identity does not matter (two Money(10, "USD") are equal) | Identity matters (two accounts with the same balance are still different accounts) |
You want equals/hashCode based on field values | You need custom equality or no equality at all |
| No inheritance needed | You need to extend or be extended |
| Fields are final and set at construction | Fields change over the object's lifetime |
A practical rule is to use records for Transaction, TransferResult, configuration snapshots, and API response payloads when they are value-oriented data. Keeping those values separate can help mutable entity classes stay focused.
Record design note
If a DTO or event payload is pure, value-oriented data with no mutable state, a record can express that intent with less boilerplate than a regular class. A record is implicitly final and shallowly immutable: its component references cannot be reassigned, but an object referenced by a component may still be mutable.
Class Design Tips
Good class design is less about patterns and more about discipline. These three rules cover many routine cases.
Single Responsibility
A class should have one reason to change. BankAccount manages balance and transaction history for a single account. It does not send emails, format reports, or talk to a database. Those responsibilities belong to other classes.
The test: can you describe what the class does in one sentence without using "and"? "BankAccount manages deposits, withdrawals, and freeze/unfreeze for a single account." One topic. If you find yourself saying "it manages accounts and generates reports and sends notifications," you have three classes hiding inside one.
Small Public Surface
Every public method is a promise. Once external code depends on it, removing or changing it is expensive. Start with the smallest viable public API and add methods only when a real caller needs them.
A useful starting point is to make members private and promote methods to public only when a test or another class actually needs them. This avoids preemptively exposing internals "in case someone needs it later."
Meaningful Names
A class name should be a noun that tells you what it is: BankAccount, Transaction, OrderItem. A method name should be a verb or verb phrase that tells you what it does: deposit, withdraw, freeze.
Avoid generic names like Manager, Handler, Processor, or Helper. They tell you nothing about what the class actually does. If you cannot name it precisely, you likely do not have a clear responsibility in mind.
Common Mistakes
1. Public fields
// Don't do this
public class User {
public String name;
public String email;
public int age;
}
Any caller can write user.age = -5. There is no validation, no logging, and little room to evolve the contract. Make fields private and use constructors or command methods to control mutation. Even for simple data carriers, a record may be a better fit: it gives value-oriented, shallow immutability with little boilerplate.
2. God classes
A god class does everything: it may grow to thousands of lines, know about the database, HTTP, email, and business rules, and require broad mocks to test.
The fix is extraction. Identify clusters of related fields and methods, then pull each cluster into its own class. OrderProcessor becomes Order (entity), OrderValidator (rules), OrderRepository (persistence), and OrderNotifier (alerts). Each is testable in isolation.
3. Mutable state everywhere
// Shared mutable list: a recipe for confusion
public class ShoppingCart {
public List<Item> items = new ArrayList<>();
}
// Somewhere else in the codebase...
cart.items.clear(); // surprise!
When fields are mutable and exposed, any code path can corrupt the object. The fix has two layers: make the field private, and return unmodifiable views or defensive copies from getters. For value objects, prefer immutable types entirely.
4. Constructor that does not validate
public BankAccount(String id, double balance) {
this.id = id; // id could be null
this.balance = balance; // balance could be -1000
}
An object born in an invalid state can cause bugs far from where it was created. Validate in the constructor, fail fast with a clear exception message, and let callers rely on the object's initial validity.
The half-built object trap
Never require a caller to call setX(...) after construction to make the object usable. If you see new Thing() followed by five setter calls, the constructor is incomplete. Demand everything up front, or provide a builder for complex construction.
Test Your Understanding
Quick Recap
- A class is a blueprint (fields + methods). An object is a runtime instance with its own copy of that state.
- Every class has four building blocks: fields for state, constructors for initialization, methods for behavior, and access modifiers for visibility control.
- Objects go through creation (constructor), usage (method calls), and garbage collection (automatic, non-deterministic).
- Constructors must validate inputs so an invalid object does not escape construction.
- Use records for immutable value objects and DTOs. Use classes for entities with mutable state and behavior.
- Start with
private. Expose only what callers actually need. Every public method is a long-term contract. - Avoid god classes, public fields, and raw setters. Small, focused classes with command methods are easier to test, reason about, and extend.
Related OOP Concepts
- Encapsulation - Uses visibility and methods to protect the state owned by an object.
- Abstraction - Separates a caller's contract from the implementation behind it.
- Association - Explains how objects collaborate by holding references to one another.
Related Articles
Encapsulation bundles data with the behavior that controls it. Private fields help enforce invariants when all mutation paths respect the object's rules.
Inheritance models IS-A relationships and enables polymorphism, but it creates tight coupling. Use composition when in doubt.
Abstraction separates what an object can do from how it does it. Callers depend on contracts, not implementations, which makes systems extensible and independently testable.