A class is a blueprint, and an object is a concrete thing built from that blueprint.
The analogy is useful. It explains why one class can be used to create many objects, just as one design can be used to manufacture many cars.
But the analogy only takes us so far.
Once we begin building real applications, more difficult questions appear:
- Which values belong together inside one object?
- Should an object be responsible for changing its own state?
- Does adding getters and setters automatically create encapsulation?
- Are two objects with the same fields actually the same object?
- Why do domain objects, services, repositories, and DTOs have different responsibilities?
- When should something be an instance method rather than a static method?
- Is a constructor merely a convenient way to assign fields?
These questions cannot be answered by saying that a class is a blueprint.
In Java, a class is more than a container for fields and methods. It defines what kind of state an object may have, what behavior it provides, and which rules must remain true throughout its lifetime.
An object is a concrete runtime entity created from that definition. It has its own state, identity, and relationships with other objects.
Understanding classes and objects is therefore not only about learning class, new, constructors, and method calls. It is about learning how to place responsibilities, protect state, and design objects that can cooperate without losing control of their own rules.
A class defines what objects of the same kind have in common
Consider a bank account.
Every account has its own account number and balance, but all accounts support similar operations such as deposits and withdrawals.
That shared structure and behavior can be represented as a class:
public class BankAccount {
private final String accountNumber;
private long balance;
public BankAccount(String accountNumber, long initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
public void deposit(long amount) {
balance += amount;
}
public void withdraw(long amount) {
balance -= amount;
}
public String getAccountNumber() {
return accountNumber;
}
public long getBalance() {
return balance;
}
}
This class does not represent one particular bank account.
It defines what every BankAccount object has in common:
- an account number
- a balance
- the ability to receive a deposit
- the ability to process a withdrawal
Multiple objects can be created from the same class:
BankAccount isaacAccount =
new BankAccount("100-001", 100_000);
BankAccount aliceAccount =
new BankAccount("100-002", 50_000);
The objects share the same class definition, but they do not share their instance state.
isaacAccount
account number: 100-001
balance: 100,000
aliceAccount
account number: 100-002
balance: 50,000
Changing one object does not automatically change the other:
isaacAccount.deposit(20_000);
System.out.println(isaacAccount.getBalance());
// 120000
System.out.println(aliceAccount.getBalance());
// 50000
The fields and method definitions belong to the class, while the values stored in instance fields belong to each individual object.
An object has state, behavior, and identity
An object is often easier to understand when viewed from three perspectives.
State
State is the data currently held by the object.
For BankAccount, the following fields form part of its state:
private final String accountNumber;
private long balance;
Two objects created from the same class can hold different values.
Behavior
Behavior is what the object can do.
public void deposit(long amount) {
balance += amount;
}
public void withdraw(long amount) {
balance -= amount;
}
Behavior is not only about calculating and returning values. It also controls how the object is allowed to change its own state.
Identity
Identity means that an object exists as a distinct entity.
Two accounts may have the same balance, but that does not make them the same account.
BankAccount first =
new BankAccount("100-001", 100_000);
BankAccount second =
new BankAccount("100-002", 100_000);
The objects have the same class and the same balance, but they represent different accounts.
This distinction becomes important when working with:
- ==
- equals()
- database entity identifiers
- value objects
- collections such as HashSet and HashMap
Having the same state is not the same as being the same object.
A class should define valid state, not just data structure
The first version of BankAccount has several problems.
It allows an account to be created with a negative balance:
BankAccount account =
new BankAccount("100-001", -100_000);
It also allows negative deposits:
account.deposit(-50_000);
And it allows withdrawals larger than the current balance:
account.withdraw(1_000_000);
All of this code is syntactically valid, but it violates the rules of the domain.
A useful class should define more than which fields exist. It should also define which states are valid.
public final class BankAccount {
private final String accountNumber;
private long balance;
public BankAccount(String accountNumber, long initialBalance) {
if (accountNumber == null || accountNumber.isBlank()) {
throw new IllegalArgumentException(
"Account number must not be blank."
);
}
if (initialBalance < 0) {
throw new IllegalArgumentException(
"Initial balance must not be negative."
);
}
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Deposit amount must be greater than zero."
);
}
balance += amount;
}
public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Withdrawal amount must be greater than zero."
);
}
if (amount > balance) {
throw new IllegalStateException(
"Insufficient balance."
);
}
balance -= amount;
}
public String accountNumber() {
return accountNumber;
}
public long balance() {
return balance;
}
}
The object now protects several rules:
- the account number cannot be blank
- the initial balance cannot be negative
- deposits and withdrawals must be positive
- the account cannot withdraw more than its balance
A condition that must remain true for an object to be valid is often called an invariant.
An invariant does not mean that the object can never change.
The balance may increase and decrease. The invariant is that the object must remain within its permitted state after every valid operation.
This is one of the most important reasons for putting behavior and state together. The object becomes the place where its own rules are enforced.
A constructor should create a usable object
A constructor is sometimes treated as a special method that copies parameters into fields.
public BankAccount(String accountNumber, long initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
That is part of what it does, but its more important responsibility is to ensure that the object begins its life in a valid state.
Consider the following style:
BankAccount account = new BankAccount();
account.setAccountNumber("100-001");
account.setBalance(100_000);
Between object creation and the setter calls, an incomplete object exists.
It may have no account number and no meaningful balance. If an exception occurs or the object is passed elsewhere before initialization is complete, invalid state can escape into the rest of the application.
Required state is usually better provided at construction time:
BankAccount account =
new BankAccount("100-001", 100_000);
The object is usable immediately after creation.
A class instance creation expression such as:
new BankAccount("100-001", 100_000)
creates a new object and invokes the selected constructor to initialize it.
In everyday Java development, it is more useful to reason about the relationship between objects and references than to think in terms of physical memory addresses. The JVM is responsible for the details of allocation and optimization.
What matters to application code is that a new object has been created, initialized, and made accessible through a reference.
A reference variable does not contain the object itself
Consider this declaration:
BankAccount account =
new BankAccount("100-001", 100_000);
The variable account does not contain the entire object.
It contains a reference to that object.
Conceptually, the relationship can be imagined like this:
account
|
+----> BankAccount object
accountNumber: "100-001"
balance: 100000
This becomes clearer when one reference is assigned to another:
BankAccount primary =
new BankAccount("100-001", 100_000);
BankAccount alias = primary;
A second bank account is not created.
The reference stored in primary is copied into alias. Both variables now refer to the same object.
primary ----+
+----> BankAccount object
alias ------+ balance: 100000
Changing the object through one reference is visible through the other:
alias.deposit(20_000);
System.out.println(primary.balance());
// 120000
This situation is known as aliasing.
Multiple references point to the same mutable object, so code using any of those references can observe changes made through the others.
Java passes object references by value
Object parameters are sometimes described as being “passed by reference.”
That wording is misleading.
Java always passes arguments by value.
When an object is passed to a method, the value being copied is the object reference.
public static void depositBonus(BankAccount account) {
account.deposit(10_000);
}
BankAccount account =
new BankAccount("100-001", 100_000);
depositBonus(account);
System.out.println(account.balance());
// 110000
The parameter variable inside depositBonus() is different from the caller’s variable.
However, both reference values point to the same BankAccount object. Mutating that object through the parameter is therefore visible to the caller.
Reassigning the parameter is different:
public static void replace(BankAccount account) {
account = new BankAccount("999-999", 0);
}
BankAccount original =
new BankAccount("100-001", 100_000);
replace(original);
System.out.println(original.accountNumber());
// 100-001
Inside replace(), the local parameter begins referring to another object.
The caller’s variable is unchanged.
The reference value was copied into the method. The caller’s variable itself was not passed into the method.
A precise description is:
Java passes object references by value.
This explains why a method can mutate an object received as an argument but cannot replace the caller’s reference merely by assigning a new object to its parameter.
The same object is not the same as an equal object
For reference types, == checks whether two references point to the same object.
BankAccount first =
new BankAccount("100-001", 100_000);
BankAccount alias = first;
System.out.println(first == alias);
// true
Both variables refer to one object.
Now compare that with two separately created objects:
BankAccount first =
new BankAccount("100-001", 100_000);
BankAccount second =
new BankAccount("100-001", 100_000);
System.out.println(first == second);
// false
Although the field values appear identical, the objects were created by separate new expressions.
first ----> BankAccount object A
second ----> BankAccount object B
To compare objects by logical value, Java uses equals().
But the meaning of equality depends on the kind of object being modeled.
A monetary value is naturally compared by value:
public record Money(long amount) {
public Money {
if (amount < 0) {
throw new IllegalArgumentException(
"Amount must not be negative."
);
}
}
}
Money first = new Money(10_000);
Money second = new Money(10_000);
System.out.println(first == second);
// false
System.out.println(first.equals(second));
// true
The instances are different objects, but they represent the same value.
An entity such as a customer, order, or bank account is different.
Two orders may have the same amount and status while still representing different business transactions.
For such objects, equality may depend on an identifier rather than every field.
This makes equals() a design decision, not merely a method that should be generated automatically.
Questions worth considering include:
- Is this object defined by its value or by its identity?
- Which fields determine equality?
- Can those fields change?
- Will the object be used as a key in a hash-based collection?
- Is equality stable during the object’s lifetime?
Whenever equals() is overridden, hashCode() must follow the same equality rules. Otherwise, collections such as HashMapand HashSet may behave incorrectly.
Encapsulation is more than making fields private
Encapsulation is often introduced by changing public fields into private fields.
private long balance;
Preventing direct access is an important first step, but it is not enough.
A public setter can expose almost the same level of control:
public void setBalance(long balance) {
this.balance = balance;
}
External code can now do this:
account.setBalance(-1_000_000);
The field is technically private, but the object’s rules are not protected.
A better design exposes meaningful operations:
account.deposit(50_000);
account.withdraw(20_000);
These methods express business intent and provide one place to enforce rules.
deposit() and withdraw() can:
- validate the amount
- verify available funds
- preserve invariants
- reject invalid transitions
- record related domain events if necessary
The purpose of encapsulation is not simply to hide data.
Encapsulation controls how state may be observed and changed.
Getters should also be added deliberately rather than automatically.
Returning an internal mutable collection can break encapsulation:
public List<OrderItem> getItems() {
return items;
}
The caller can modify the internal collection directly:
order.getItems().clear();
A safer alternative is to return an immutable copy:
public List<OrderItem> items() {
return List.copyOf(items);
}
Sometimes the collection does not need to be exposed at all.
The object can provide only the information callers actually need:
public int itemCount() {
return items.size();
}
public long totalAmount() {
return items.stream()
.mapToLong(OrderItem::amount)
.sum();
}
Encapsulation involves deciding which parts of the object’s representation should remain internal and which operations should form its public contract.
Behavior should often live near the state it protects
Consider a service that performs withdrawals by reading and writing account fields:
public void withdraw(BankAccount account, long amount) {
if (amount <= 0) {
throw new IllegalArgumentException();
}
if (account.getBalance() < amount) {
throw new IllegalStateException();
}
account.setBalance(
account.getBalance() - amount
);
}
The withdrawal rules live in the service, while the account acts only as a data container.
This can work, but the rules may eventually be duplicated across services, batch jobs, event consumers, or administrative tools.
One path may check the balance while another may forget:
account.setBalance(
account.getBalance() - amount
);
When the behavior belongs to the object, the rule has a single home:
account.withdraw(amount);
The object remains responsible for protecting its own state.
A service can then focus on coordinating several objects or external systems:
@Transactional
public void transfer(
BankAccount sender,
BankAccount receiver,
long amount
) {
sender.withdraw(amount);
receiver.deposit(amount);
}
The service coordinates the transfer:
- withdraw from the sender
- deposit into the receiver
- execute the operation within a transaction
Each account remains responsible for its own balance rules.
This does not mean that every operation must be placed inside a domain object.
Database access, email delivery, external API calls, and transaction coordination usually belong elsewhere.
A practical division of responsibility may look like this:
- an object manages its own state and domain rules
- a service coordinates workflows involving several objects
- a repository stores and retrieves objects
- a controller translates external requests and responses
- an infrastructure component communicates with external systems
Understanding classes and objects eventually becomes a question of responsibility: which part of the system should own each rule and each decision?
Instance members and static members belong to different levels
Instance members belong to a specific object.
private long balance;
public void deposit(long amount) {
balance += amount;
}
Each BankAccount object has its own balance.
A static member belongs to the class rather than to one particular instance.
public class BankAccount {
private static final long MINIMUM_DEPOSIT = 1_000;
private long balance;
}
MINIMUM_DEPOSIT represents a rule shared by all accounts. It does not need to be stored separately in every object.
A static method is appropriate when its behavior does not depend on instance state:
public static boolean isValidAccountNumber(String value) {
return value != null
&& value.matches("\\d{3}-\\d{3}");
}
This method does not read the balance or account number of any particular object.
On the other hand, turning all behavior into static functions can weaken the object model:
public static void withdraw(
BankAccount account,
long amount
) {
// ...
}
This is not automatically wrong, but it may leave the account unable to protect its own rules.
The important question is not whether a static method is more convenient.
The question is whether the behavior conceptually belongs to a specific object, to the class as a whole, or to a separate service.
Immutable objects reduce the number of state changes to reason about
Not every object needs to change after creation.
Values such as money, coordinates, date ranges, email addresses, and identifiers are often easier to model as immutable objects.
public final class EmailAddress {
private final String value;
public EmailAddress(String value) {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException(
"Invalid email address."
);
}
this.value = value;
}
public String value() {
return value;
}
}
The internal value cannot be changed after construction.
When a different value is needed, a new object is created instead of mutating the existing one.
public record Money(long amount) {
public Money {
if (amount < 0) {
throw new IllegalArgumentException(
"Amount must not be negative."
);
}
}
public Money add(Money other) {
return new Money(amount + other.amount);
}
}
Money original = new Money(10_000);
Money added = original.add(new Money(5_000));
original still represents 10,000, while added represents 15,000.
Immutable objects provide several advantages:
- their state cannot change unexpectedly
- they are easier to share safely
- they reduce concurrency risks
- their equality and hash code remain stable
- tests become easier to reason about
- fewer parts of the program need to track mutation
A useful design distinction is whether a class represents an entity whose state changes over time or a value that should remain stable once created.
A class should not become the home of every related operation
Finding nouns in a requirement and turning each noun into a class is not enough to create a good object model.
Responsibility matters more than the number of classes.
Consider an Order class that handles everything related to orders:
public class Order {
public void addItem() {
// add an item
}
public void calculatePrice() {
// calculate total price
}
public void saveToDatabase() {
// persist the order
}
public void sendConfirmationEmail() {
// send email
}
public void createInvoicePdf() {
// generate PDF
}
}
The class appears convenient because all order-related behavior is in one place.
But it can now change for many unrelated reasons:
- pricing rules change
- the database schema changes
- the email provider changes
- the invoice format changes
- order policies change
Different concerns are coupled together.
A clearer separation might be:
Order
- manages order state and domain rules
OrderRepository
- saves and retrieves orders
OrderNotificationService
- sends order-related notifications
InvoiceGenerator
- creates invoice documents
A good class does not have to be tiny.
The goal is not to minimize the number of methods.
A good class has a coherent responsibility and changes for reasons connected to that responsibility.
Spring applications are still built from cooperating objects
Spring introduces annotations such as:
- @Controller
- @Service
- @Repository
- @Component
For example:
@Service
public class TransferService {
private final AccountRepository accountRepository;
public TransferService(
AccountRepository accountRepository
) {
this.accountRepository = accountRepository;
}
}
This is still an ordinary Java class.
Spring creates and manages an object based on that class, supplies its dependencies, and makes it available to the rest of the application. Such managed objects are commonly called Spring Beans.
Dependency injection is a way of constructing relationships between objects from outside those objects.
TransferController
|
v
TransferService
|
v
AccountRepository
Each object has a responsibility and collaborates with others.
This is why the basic distinction between classes and objects remains important even in framework-heavy applications.
Without it, it becomes difficult to answer questions such as:
- Should this rule belong to the entity or the service?
- Should this class hold mutable state?
- Why is this dependency provided through the constructor?
- Can this object safely be shared between requests?
- Why should a DTO be separated from a domain object?
- Which dependency should be replaced in a unit test?
Spring Beans are often singleton-scoped by default, meaning one object may be shared across many requests.
Storing request-specific mutable state in an instance field can therefore cause concurrency bugs.
@Service
public class UnsafeOrderService {
private Long currentOrderId;
public void process(Long orderId) {
this.currentOrderId = orderId;
// ...
}
}
Two concurrent requests may overwrite the same field.
Request-specific state should normally remain in local variables:
@Service
public class OrderService {
public void process(Long orderId) {
Long currentOrderId = orderId;
// ...
}
}
A basic understanding of object state directly affects the safety of real server applications.
Understanding objects changes the way classes are written
At first, a class may look like little more than a convenient place to group fields.
public class Member {
public Long id;
public String name;
public String email;
public int age;
}
Grouping related values is useful, but the object has no control over its own validity.
Member member = new Member();
member.age = -100;
member.email = "not-an-email";
Once the class is treated as the owner of a concept and its rules, the design begins to change.
import java.util.Objects;
public final class Member {
private final Long id;
private String name;
private EmailAddress email;
private int age;
public Member(
Long id,
String name,
EmailAddress email,
int age
) {
validateName(name);
validateAge(age);
this.id = id;
this.name = name;
this.email = Objects.requireNonNull(email);
this.age = age;
}
public void changeName(String newName) {
validateName(newName);
this.name = newName;
}
public void changeEmail(EmailAddress newEmail) {
this.email = Objects.requireNonNull(newEmail);
}
private static void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException(
"Name must not be blank."
);
}
}
private static void validateAge(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"Age must not be negative."
);
}
}
}
This object does more than store member data.
- it requires valid state during construction
- it controls how the name may change
- it uses a separate value object for email validation
- it prevents direct modification of its fields
- it keeps validation rules close to the state they protect
This does not mean that every class in every project must be designed this way.
Framework requirements, persistence models, performance constraints, and team conventions still matter.
The important habit is to think before generating getters and setters automatically.
What does this object represent? Which rules should it protect? Who should be allowed to change its state?
Classes organize code, but objects carry responsibility
Classes and objects are among the first concepts taught in Java, yet they continue to matter as systems become more complex.
A class is not merely a file that contains fields and methods.
It defines the state, behavior, and rules of a concept in the program.
An object is a concrete instance of that definition. It has its own state and identity, and it participates in relationships with other objects through references.
Once this distinction is clear, several Java features begin to fit together:
- constructors create valid starting states
- encapsulation controls how state may be observed and changed
- methods let objects protect their own rules
- references allow multiple parts of the program to reach the same object
- == and equals() distinguish identity from logical equality
- immutable objects represent stable values safely
- services coordinate work across objects and infrastructure
- dependency injection connects objects without forcing them to construct each other
Good object-oriented code does not come from turning every noun into a class.
It also does not come from moving every piece of logic into an object.
It comes from placing each rule where it can be understood and protected, then allowing objects to collaborate through clear boundaries.
Classes and objects are considered basic Java concepts, but basic does not mean shallow.
As an application grows, the same design questions return again and again:
Which object should own this state?
Who should be allowed to change it?
Which class should enforce this rule?
Learning how to answer those questions is where understanding classes and objects becomes practical software design.
References
- Java Language Specification, Chapter 4: Types, Values, and Variables — Oracle
- Java Language Specification, Chapter 8: Classes — Oracle
- Java Language Specification, Chapter 15: Expressions — Oracle
- Calling Methods and Constructors — Dev.java / Oracle
- Object — Java SE API / Oracle
- Using Records to Model Immutable Data — Dev.java / Oracle
- List — Java SE API / Oracle
- Anemic Domain Model — Martin Fowler
- Repository — Martin Fowler
- Service Layer — Martin Fowler
- Dependency Injection — Spring Framework
- Bean Scopes — Spring Framework
- Classpath Scanning and Managed Components — Spring Framework
