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.
Every object-oriented program you will ever write starts with two concepts: a class that describes what something is, and an object that is that something. Get these right and the rest of OOP (encapsulation, inheritance, polymorphism) falls into place naturally. Get them wrong and you end up with data bags, god classes, and code that fights you at every turn.
What Are Classes and Objects
A class is a blueprint. It declares the fields an entity holds and the methods it can perform, but it occupies no memory at runtime beyond the class definition itself. An object is a living instance of that blueprint, allocated on the heap, holding its own copy of every instance field.
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 code but own separate state. Modifying one never touches the other. That independence is the whole point.
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 almost always 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<>();
I 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. Never leave 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 |
For your interviews: "start private, widen only with a reason" is a one-liner that signals you think about API surface area.
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 reclaims its memory. You do not call free() or delete. The timing is non-deterministic, which is why you should never put critical cleanup logic in finalize(). Use try-with-resources for closable resources like file handles and database connections.
Stack vs heap
Local variables and references live on the stack. The objects they point to live on the heap. When you write BankAccount alice = new BankAccount(...), the variable alice (a pointer) is on the stack. The actual BankAccount data is on the heap. When alice goes out of scope, the stack entry vanishes, but the heap object persists until GC collects it.
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 |
I use records aggressively. Transaction, TransferResult, configuration snapshots, API response payloads: anything that is "just data" gets a record. My mutable entity classes stay small because the data they produce flows out as records.
Interview signal
Mentioning records when discussing value objects signals that you write modern Java. If you are designing a system and need a DTO or event payload, say "I would model this as a record since it is pure data with no mutable state." That one sentence shows you know when classes are overkill.
Class Design Tips
Good class design is less about patterns and more about discipline. These three rules handle 90% of 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.
I often start a new class with everything private and promote methods to public only when a test or another class actually demands it. This avoids the trap of 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, no future flexibility. Make fields private and use constructors or command methods to control mutation. Even for simple data carriers, a record is better: it gives you immutability with zero boilerplate.
2. God classes
A god class does everything. 2000 lines, 40 methods, knows about the database, the HTTP layer, the email service, and business rules. Testing it requires mocking the entire universe.
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 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 will cause bugs far from where it was created. Validate in the constructor. Fail fast with a clear exception message. The caller should never wonder whether the object they hold is valid.
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. An object should never exist in an invalid state.
- 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.