1. Introduction
Encapsulation is the practice of protecting an object's internal state and exposing controlled operations through a clear public API.
In real Java applications, encapsulation is much more than declaring fields as private and generating getters and setters.
Good encapsulation means:
- Objects protect their own valid state.
- Callers cannot modify internal data in unsafe ways.
- Business rules are enforced close to the data they protect.
- Internal implementation details can change without breaking every caller.
- Public methods expose meaningful business operations rather than raw state manipulation.
For example, an Order should not allow every caller to directly change its status:
order.setStatus(OrderStatus.COMPLETED);If completing an order requires business checks, those rules should be protected inside the object or an appropriate domain service.
A better API may be:
order.complete();The complete() method can enforce:
- Payment must be successful.
- Order must not already be cancelled.
- Required items must exist.
- Current state transition must be valid.
This is practical encapsulation.
In Java code review, reviewers should therefore look beyond access modifiers and ask:
Can callers put this object into an invalid state?
2. What This Topic Means
Consider a basic customer account class.
public class CustomerAccount {
public BigDecimal balance;
public AccountStatus status;
}Any caller can do:
account.balance = new BigDecimal("-500000");
account.status = AccountStatus.CLOSED;There is no control over when or why these values change.
Changing fields to private is an improvement:
public class CustomerAccount {
private BigDecimal balance;
private AccountStatus status;
}However, adding unrestricted setters may bring the same problem back:
public void setBalance(BigDecimal balance) {
this.balance = balance;
}
public void setStatus(AccountStatus status) {
this.status = status;
}Now any caller can still execute:
account.setBalance(new BigDecimal("-500000"));Real encapsulation is stronger.
The class should expose meaningful behavior:
public void credit(BigDecimal amount) {
validateAmount(amount);
balance = balance.add(amount);
}
public void debit(BigDecimal amount) {
validateAmount(amount);
if (balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
balance = balance.subtract(amount);
}The object controls how its internal state changes.
That is the important practical meaning of encapsulation.
3. Why It Matters in Real Projects
Maintainability
When internal implementation details are hidden, they can change without affecting every caller.
For example, an order may initially store:
private BigDecimal totalAmount;Later, the implementation may calculate the total from order items.
If callers use:
order.getTotalAmount();the internal representation can change with limited impact.
Reliability
Encapsulated objects prevent invalid state transitions.
For example:
payment.markSuccessful();can enforce that a failed or refunded payment cannot be arbitrarily changed to another state.
Debugging
When state changes happen through controlled methods, developers have fewer mutation paths to investigate.
Instead of searching for:
setStatus(...)through dozens of classes, reviewers can inspect:
approve()
cancel()
complete()
refund()Readability
Business methods communicate intent.
Compare:
order.setStatus(OrderStatus.CANCELLED);with:
order.cancel(reason);The second operation explains what the application is doing.
Team Development
Developers can modify internals without forcing other teams to understand implementation details.
Security
Encapsulation can reduce accidental exposure or modification of:
- Password hashes
- Roles
- Account balances
- Tokens
- Internal identifiers
- Sensitive collections
It does not replace authorization, but it creates safer object boundaries.
Testability
Behavior-focused APIs make tests clearer.
Instead of testing a sequence of setters, tests can verify meaningful operations and business invariants.
4. Core Concept
The core idea is:
Expose behavior that callers need, while hiding internal representation and protecting invariants.
An invariant is a rule that should always remain true for an object.
For example, an order may have rules such as:
- Quantity must be positive.
- Total amount cannot be negative.
- A cancelled order cannot be shipped.
- A delivered order cannot return to
CREATED. - A refund cannot exceed the paid amount.
If fields are freely writable, every caller becomes responsible for preserving these rules.
That is fragile.
A better approach is to make the object responsible for protecting its state.
Example:
public class Order {
private OrderStatus status;
private final List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
Objects.requireNonNull(item, "item must not be null");
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("Items can only be added to a new order");
}
items.add(item);
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateException("Shipped or delivered order cannot be cancelled");
}
status = OrderStatus.CANCELLED;
}
}The class owns the rules controlling its state.
Encapsulation Is Not the Same as Data Hiding
Data hiding is one part of encapsulation.
Encapsulation also includes:
- Controlled mutation
- Business invariants
- Minimal public API
- Defensive copying
- Appropriate visibility
- Hiding implementation details
- Meaningful methods
- Protection of collections
- Preventing invalid transitions
5. Important Rules
When writing or reviewing Java code:
- Keep internal fields
privateunless wider visibility has a clear reason. - Do not automatically create setters for every field.
- Expose business operations instead of unrestricted state mutation.
- Protect object invariants inside the object or appropriate domain boundary.
- Validate state changes before applying them.
- Avoid exposing mutable internal collections directly.
- Return immutable views or defensive copies when appropriate.
- Copy mutable input collections if external modification could affect internal state.
- Use
finalfor references that should not be reassigned. - Keep helper methods
privatewhen callers do not need them. - Avoid public methods that exist only to expose implementation details.
- Use package-private visibility where package-level collaboration is intentional.
- Avoid exposing framework or persistence internals through domain APIs.
- Do not expose passwords, tokens, secrets, or sensitive fields unnecessarily.
- Keep state transitions explicit.
- Prefer meaningful methods such as
activate(),cancel(),addItem()andwithdraw()over generic setters. - Use immutable value objects when mutation is unnecessary.
- Review generated Lombok setters carefully rather than adding
@Dataautomatically. - Ensure serialization requirements do not accidentally expose sensitive properties.
- Avoid over-encapsulation that makes simple data-transfer objects unnecessarily complex.
6. Bad Code Example
Consider an e-commerce order entity.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
public Long customerId;
public BigDecimal totalAmount;
@Enumerated(EnumType.STRING)
public OrderStatus status;
@OneToMany(cascade = CascadeType.ALL)
public List<OrderItem> items = new ArrayList<>();
}A service directly manipulates its internal state.
@Service
public class OrderManagementService {
private final OrderRepository orderRepository;
public OrderManagementService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void addItem(Long orderId, OrderItem item) {
Order order = getOrder(orderId);
order.items.add(item);
order.totalAmount = order.totalAmount.add(item.getTotalPrice());
orderRepository.save(order);
}
public void cancel(Long orderId) {
Order order = getOrder(orderId);
order.status = OrderStatus.CANCELLED;
orderRepository.save(order);
}
public void changeTotal(Long orderId, BigDecimal amount) {
Order order = getOrder(orderId);
order.totalAmount = amount;
orderRepository.save(order);
}
private Order getOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}This design gives external code almost complete control over the order's internal state.
7. Problems in the Bad Code
Public Mutable Fields
Every part of the application can modify:
totalAmount
status
itemswithout validation.
Business Rules Are Outside the Object
The service is responsible for remembering how to update:
items
totalAmounttogether.
Another developer may add an item without recalculating the total.
Invalid State Is Easy to Create
Any caller can execute:
order.status = OrderStatus.DELIVERED;even when the order has never been shipped.
Arbitrary Financial Modification
This method is dangerous:
changeTotal(Long orderId, BigDecimal amount)It allows callers to set any total directly.
Mutable Collection Exposure
The items list is publicly accessible.
Callers can:
order.items.clear();without preserving order rules.
Tight Coupling to Representation
The service knows that:
- Order has a mutable list.
- Total is stored separately.
- Adding an item requires manual total recalculation.
If the internal representation changes, all such callers may need modification.
Bug Risk
Different code paths may update state differently.
Audit Difficulty
It becomes hard to determine why an order status or amount changed because mutations can occur anywhere.
8. Code Review Findings
A senior reviewer should notice the following.
Finding 1
Entity state is publicly mutable.
No class-level invariant protects the order.
Finding 2
OrderManagementService manipulates fields instead of invoking domain behavior.
Finding 3
items exposes a mutable internal collection.
Any caller can modify it without updating the order total.
Finding 4
changeTotal() exposes an unrestricted financial-state mutation.
Finding 5
Status transitions are not validated.
A DELIVERED order could potentially be changed to CANCELLED directly.
Finding 6
The service depends on the internal representation of Order.
This increases coupling.
Finding 7
The same business rules can easily be duplicated across multiple services.
9. Reviewer Comment Example
A practical Pull Request comment could be:
Ordercurrently exposes mutable fields publicly, so callers can bypass business validation. Could we make the state private and expose operations such asaddItem()andcancel()that preserve the order invariants?
Another:
Returning or exposing the mutable
itemscollection allows callers to modify order contents without recalculating the total. Please protect the collection and expose controlled item operations.
Another:
changeTotal()allows arbitrary modification of a financial value. If total amount is derived from order items, consider calculating it internally instead of exposing a generic setter.
10. Improved Code
A better implementation protects the order state.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private final List<OrderItem> items = new ArrayList<>();
protected Order() {
}
public Order(Long customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId must not be null");
this.status = OrderStatus.CREATED;
}
public Long getId() {
return id;
}
public Long getCustomerId() {
return customerId;
}
public OrderStatus getStatus() {
return status;
}
public List<OrderItem> getItems() {
return List.copyOf(items);
}
public BigDecimal getTotalAmount() {
return items.stream()
.map(OrderItem::getTotalPrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public void addItem(OrderItem item) {
Objects.requireNonNull(item, "item must not be null");
ensureEditable();
items.add(item);
}
public void removeItem(Long productId) {
ensureEditable();
boolean removed = items.removeIf(item -> item.getProductId().equals(productId));
if (!removed) {
throw new OrderItemNotFoundException(productId);
}
}
public void confirm() {
if (items.isEmpty()) {
throw new IllegalStateException("Order cannot be confirmed without items");
}
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("Only created orders can be confirmed");
}
status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateException("Shipped or delivered order cannot be cancelled");
}
if (status == OrderStatus.CANCELLED) {
return;
}
status = OrderStatus.CANCELLED;
}
private void ensureEditable() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("Order items can only be changed while order is created");
}
}
}The service becomes simpler.
@Service
public class OrderManagementService {
private final OrderRepository orderRepository;
public OrderManagementService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void addItem(Long orderId, OrderItem item) {
Order order = getOrder(orderId);
order.addItem(item);
}
@Transactional
public void cancel(Long orderId) {
Order order = getOrder(orderId);
order.cancel();
}
private Order getOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}11. Improved Code Explanation
Fields Are Private
Callers cannot modify entity state directly.
No Generic Status Setter
The object exposes:
confirm()
cancel()instead of:
setStatus(...)This keeps state transitions meaningful.
Total Amount Is Derived
The amount is calculated from items rather than allowing arbitrary assignment.
This removes the risk of:
item total != order totalCollection Is Protected
getItems() returns:
List.copyOf(items)The caller cannot modify the internal collection through the returned reference.
Item Changes Are Controlled
Adding or removing items goes through methods that verify the order is editable.
Business Rules Stay With State
The object protects:
- Valid item mutation
- Valid confirmation
- Valid cancellation
Service Becomes an Orchestrator
OrderManagementService handles:
- Loading the entity
- Calling business behavior
It no longer manually understands every state rule.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| Field access | Public mutable fields | Private state |
| Status changes | Direct assignment | Controlled operations |
| Order total | Arbitrary mutation | Derived internally |
| Collections | Direct mutable access | Protected view |
| Business rules | Spread across services | Protected near state |
| Maintainability | Callers know internals | Internals can evolve |
| Testability | Must test external mutation paths | Behavior-focused tests |
| Reliability | Invalid states easy to create | Invalid transitions rejected |
| Readability | Generic mutations | Intent-revealing operations |
13. Real Project Scenario
Consider a banking system containing:
BankAccountThe initial implementation exposes:
setBalance()
setStatus()
setDailyWithdrawalLimit()Multiple services use these setters directly.
A withdrawal service does:
account.setBalance(
account.getBalance().subtract(amount)
);A scheduled-fee job does:
account.setBalance(
account.getBalance().subtract(fee)
);An adjustment API does:
account.setBalance(newBalance);Eventually several problems appear:
- Negative balances bypass overdraft rules.
- Some changes are not audited.
- Daily withdrawal limits are bypassed.
- Frozen accounts can still be modified.
- Different services use different rounding logic.
A more encapsulated design exposes operations such as:
account.withdraw(amount);
account.deposit(amount);
account.applyFee(fee);
account.freeze(reason);
account.changeDailyLimit(newLimit);Each operation can consistently enforce:
- Account status
- Amount validation
- Balance rules
- Audit metadata
- Limits
- Currency rules
The internal balance field remains protected.
This dramatically reduces the number of places capable of creating invalid account state.
14. Production Impact
Poor encapsulation can create serious production problems.
Invalid Business State
Objects may reach states that should be impossible.
Example:
order = DELIVERED
payment = FAILEDFinancial Errors
Direct modification of:
- Balance
- Price
- Refund amount
- Discount
- Tax
can bypass business rules.
Difficult Debugging
When dozens of callers can modify the same field, finding the source of incorrect state becomes difficult.
Data Inconsistency
Related fields may be updated independently.
For example:
items updated
totalAmount not updatedSecurity Problems
Sensitive information may be exposed through:
- Public fields
- Getters
- Serialization
- Logging
- Mutable objects
Regression Risk
Changing internal representation breaks callers that rely on implementation details.
Concurrent Modification Problems
Exposed mutable collections can be modified unexpectedly by multiple callers.
Maintenance Cost
Every new business invariant has to be enforced across many mutation points rather than one controlled boundary.
15. Common Developer Mistakes
Mistake 1: Private Fields Plus Setters Equals Encapsulation
Not necessarily.
This:
private BigDecimal balance;
public void setBalance(BigDecimal balance) {
this.balance = balance;
}still exposes unrestricted state mutation.
Mistake 2: Lombok @Data on Domain Entities
Using:
@Datamay generate setters for every field.
That can expose fields that should be controlled.
Use Lombok deliberately.
Mistake 3: Returning Mutable Collections
Example:
public List<OrderItem> getItems() {
return items;
}A caller can modify internal state.
Mistake 4: Saving Mutable Input Collections Directly
Example:
this.roles = roles;If the caller later executes:
roles.clear();the object's internal state may also change.
Mistake 5: Generic setStatus()
Status usually represents a workflow.
Prefer meaningful transition methods when rules exist.
Mistake 6: Public Entity Fields
This removes control over persistence-domain state.
Mistake 7: Exposing Sensitive Fields
Example:
public String getPasswordHash()may unnecessarily expose sensitive implementation data.
Mistake 8: Putting All Rules in Services
If every service manually checks entity invariants, rules can become duplicated and inconsistent.
Mistake 9: Returning Internal Maps or Sets
Mutable internal collections require the same protection as lists.
Mistake 10: Making Everything Immutable Without Need
Encapsulation does not require every object to be immutable.
Some domain objects naturally change state through controlled operations.
16. Edge Cases
JPA Requirements
JPA entities commonly require a no-argument constructor.
It does not need to be publicly exposed in every design.
For example:
protected Order() {
}can satisfy JPA while limiting normal application use.
Framework Field Access
JPA can use field access when annotations are placed on fields.
Therefore, public setters are not automatically required for persistence.
Jackson Serialization
Jackson may require properties to be serializable depending on configuration.
Do not expose domain internals only to satisfy API serialization.
Consider API DTOs where appropriate.
Mutable Date Types
Modern Java applications should generally prefer immutable types such as:
LocalDate
LocalDateTime
Instantover older mutable date representations.
Arrays
Returning an internal array directly exposes mutable state.
Bad:
public byte[] getDocument() {
return document;
}Safer where needed:
public byte[] getDocument() {
return document.clone();
}Collections
Use defensive copying carefully.
For example:
List.copyOf(items)returns an unmodifiable copy.
Null Values
Constructor and mutation methods should define whether null is valid.
Concurrency
Encapsulation does not automatically make an object thread-safe.
Mutable objects shared between threads still require appropriate concurrency design.
ORM Proxies
Entity methods and visibility should be designed with ORM behavior in mind without exposing unnecessary public mutation.
17. Performance Considerations
Encapsulation is mainly a correctness and maintainability concern.
Getter or method-call overhead is normally insignificant in modern Java applications.
However, some encapsulation techniques can affect performance.
Defensive Copying
Returning:
List.copyOf(items)creates a copy.
For very large collections or extremely frequent calls, this may allocate additional memory.
Alternatives may include carefully designed unmodifiable views, depending on the ownership model.
Derived Values
Calculating:
getTotalAmount()by iterating through all items each time has O(n) cost.
If the collection is large and the value is requested frequently, a cached total may be appropriate.
However, caching reintroduces consistency requirements.
The design should balance:
- Correctness
- Simplicity
- Performance
Large byte[]
Defensive copying of large binary arrays can be expensive.
Consider whether:
- Streaming
- Immutable wrappers
- Resource abstractions
are more appropriate.
Object Creation
Immutable objects may create new instances for state changes.
Usually this is acceptable unless profiling demonstrates a real problem.
Do not weaken encapsulation based on speculative micro-optimization.
18. Security Considerations
Encapsulation can support security by limiting exposure of sensitive state.
Passwords
Avoid exposing password hashes unnecessarily.
A user object should not casually provide:
getPasswordHash()to every caller.
Tokens
Do not expose:
- Access tokens
- Refresh tokens
- API keys
- Session secrets
through broad domain APIs.
Roles and Permissions
Avoid mutable role collections such as:
getRoles().add(ADMIN);Prefer controlled methods that enforce authorization and domain rules.
Account Balances
Financial state should change only through validated operations.
API Serialization
A getter may accidentally cause sensitive information to appear in JSON responses.
Review:
- Jackson visibility
- DTO mappings
@JsonIgnore- API-specific response models
Logging
An encapsulated field can still leak if toString() includes it.
Review generated Lombok toString() behavior for entities containing secrets.
Encapsulation helps reduce accidental exposure, but it does not replace authentication or authorization.
19. Testing Considerations
Encapsulated domain behavior should be tested through its public operations.
Positive Test: Add Item
Verify that adding an item updates the visible order state.
@Test
void shouldAddItemWhenOrderIsCreated() {
Order order = new Order(100L);
OrderItem item = createOrderItem();
order.addItem(item);
assertEquals(1, order.getItems().size());
assertEquals(item.getTotalPrice(), order.getTotalAmount());
}Negative Test: Modify Confirmed Order
Verify that items cannot be changed after confirmation.
@Test
void shouldRejectItemChangeAfterConfirmation() {
Order order = new Order(100L);
order.addItem(createOrderItem());
order.confirm();
assertThrows(
IllegalStateException.class,
() -> order.addItem(createOrderItem())
);
}Collection Protection Test
Verify that the returned collection cannot modify the internal state.
@Test
void shouldNotExposeMutableItemsCollection() {
Order order = new Order(100L);
order.addItem(createOrderItem());
List<OrderItem> items = order.getItems();
assertThrows(
UnsupportedOperationException.class,
items::clear
);
}State Transition Tests
Test allowed and forbidden transitions:
- CREATED -> CONFIRMED
- CREATED -> CANCELLED
- CONFIRMED -> CANCELLED if allowed
- SHIPPED -> CANCELLED should fail
- DELIVERED -> CREATED should be impossible
Boundary Tests
Test:
- Zero amount
- Negative amount
- Empty collection
- Null input
- Duplicate operations
Persistence Tests
For JPA entities, verify important encapsulated state is persisted and restored correctly.
20. Refactoring Guidelines
Refactoring poorly encapsulated code should be done gradually.
Step 1: Find Direct Field Access
Search for:
public fields
package-visible mutable fields
direct collection mutationStep 2: Identify Dangerous Setters
Look for methods such as:
setBalance()
setStatus()
setTotal()
setRole()Ask whether the caller should truly control these values directly.
Step 3: Identify Invariants
Write down business rules that must always remain true.
For example:
- Balance cannot violate account rules.
- Cancelled order cannot be shipped.
- Refund cannot exceed payment amount.
Step 4: Introduce Business Operations
Replace generic mutation with methods such as:
withdraw()
cancel()
refund()
activate()
addItem()Step 5: Protect Collections
Replace direct mutable exposure with:
List.copyOf(...)
Set.copyOf(...)
Map.copyOf(...)where suitable.
Step 6: Move Validation Closer to State
Move repeated invariant checks from callers into the object or a dedicated domain component.
Step 7: Migrate Callers
Update services gradually.
Step 8: Remove Unsafe Setters
Delete generic setters once all callers use controlled operations.
Step 9: Add Tests
Protect state-transition behavior before and during refactoring.
Step 10: Keep Persistence Compatibility in Mind
For entities, verify:
- ORM constructor requirements
- Mapping behavior
- Lazy collections
- Persistence tests
21. Best Practices
- Keep domain state private.
- Expose behavior rather than arbitrary state changes.
- Use meaningful state-transition methods.
- Validate changes before modifying state.
- Protect mutable collections.
- Use defensive copies when ownership matters.
- Prefer immutable value objects for concepts such as money, identifiers, and date ranges when appropriate.
- Use
finalwhere reassignment is unnecessary. - Keep helper methods private.
- Limit API surface area.
- Use DTOs when API representation should differ from domain representation.
- Keep sensitive values out of general getters.
- Review Lombok-generated methods explicitly.
- Keep object invariants close to the object that owns the state.
- Do not expose JPA details merely for convenience.
- Design constructors that establish valid initial state.
- Keep state transitions explicit and testable.
- Avoid redundant derived fields when they can become inconsistent.
- Document unusual mutation rules.
- Prefer business terminology in public methods.
22. Practices to Avoid
Public Mutable Fields
Avoid:
public BigDecimal balance;Setter for Every Field
Avoid generating unrestricted setters automatically for business entities.
Generic State Setter
Avoid:
setStatus(...)when status represents a controlled workflow.
Mutable Collection Getter
Avoid:
return internalList;Internal Collection Reference Assignment
Avoid:
this.items = items;when the caller still owns and can modify items.
Sensitive Getters
Avoid exposing secrets or credentials without a specific requirement.
Huge Public API
Do not make helper behavior public simply because testing or another class might someday need it.
Logic-Free Domain Objects by Default
Not every entity must contain behavior, but forcing all invariants into external services can produce duplicated rules.
Excessive Encapsulation
Do not wrap trivial data-transfer objects in unnecessary business methods.
DTOs often exist primarily to transport data.
Reflection-Based Workarounds
Do not weaken design just because a framework can bypass access control.
Design the public API for application callers first, then configure persistence or serialization appropriately.
23. Code Review Checklist
Ask these questions during Pull Request review:
- Are internal fields appropriately private?
- Can callers modify important state without validation?
- Does this class expose setters for fields that represent business rules?
- Should this setter be replaced with a meaningful business operation?
- Can this object be placed into an invalid state through its public API?
- Are state transitions validated?
- Is a mutable collection exposed directly?
- Is mutable input stored without defensive copying?
- Can external code clear or modify an internal collection?
- Is derived state stored redundantly and at risk of becoming inconsistent?
- Does this getter expose sensitive information?
- Could JSON serialization expose confidential fields?
- Is Lombok
@Datagenerating methods that should not be public? - Are business invariants duplicated in several service classes?
- Does the class protect financial values from arbitrary mutation?
- Is a public helper method exposing implementation details?
- Are constructor arguments validated?
- Does the object start in a valid state?
- Are JPA requirements being satisfied without exposing unnecessary mutation?
- Are immutable value objects appropriate for any fields?
- Are arrays or other mutable objects defensively copied where necessary?
- Is the class's public API smaller than its internal implementation?
- Can implementation details change without modifying many callers?
- Are concurrency assumptions clear for mutable shared objects?
24. Common Pull Request Review Comments
This entity exposes status through a public setter, which allows callers to bypass the order-state transition rules. Could we replace it with explicit operations such as confirm() and cancel()?
getItems() currently returns the internal mutable list. A caller can modify the order without going through validation. Please return a protected view or copy.
Setting totalAmount directly can make it inconsistent with the order items. Consider deriving the total or updating it only through controlled item operations.
@Data will generate setters for all fields, including fields that appear to represent protected domain state. Could we use targeted Lombok annotations instead?
This method stores the caller's mutable list directly. Please consider making a defensive copy so later caller changes cannot modify internal state.
The service repeats the same account-balance validation used in other workflows. This invariant may be safer inside the account's debit/withdraw operation.
The passwordHash getter exposes sensitive internal state to every consumer of this entity. Is this getter actually required?
Changing the order status through setStatus() does not verify whether the transition is legal. Please keep the transition logic behind a domain operation.
This private helper does not appear to be needed outside the class. Please keep its visibility private rather than expanding the public API.
The API response is serializing the persistence entity directly. A dedicated response DTO would give us better control over which fields are exposed.
25. Code Review Exercise
Review the following Spring Boot/JPA code.
@Entity
public class Wallet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
public Long customerId;
public BigDecimal balance;
public WalletStatus status;
@ElementCollection
public List<String> transactionReferences = new ArrayList<>();
public void setBalance(BigDecimal balance) {
this.balance = balance;
}
public void setStatus(WalletStatus status) {
this.status = status;
}
public List<String> getTransactionReferences() {
return transactionReferences;
}
}
@Service
public class WalletService {
private final WalletRepository walletRepository;
public WalletService(WalletRepository walletRepository) {
this.walletRepository = walletRepository;
}
@Transactional
public void withdraw(Long walletId, BigDecimal amount) {
Wallet wallet = getWallet(walletId);
wallet.setBalance(wallet.balance.subtract(amount));
wallet.transactionReferences.add("WITHDRAW-" + UUID.randomUUID());
}
@Transactional
public void freeze(Long walletId) {
Wallet wallet = getWallet(walletId);
wallet.setStatus(WalletStatus.FROZEN);
}
@Transactional
public void activate(Long walletId) {
Wallet wallet = getWallet(walletId);
wallet.setStatus(WalletStatus.ACTIVE);
}
private Wallet getWallet(Long walletId) {
return walletRepository.findById(walletId)
.orElseThrow(() -> new WalletNotFoundException(walletId));
}
}Identify:
- Encapsulation problems
- Invalid-state risks
- Collection exposure
- Financial risks
- State-transition problems
- Testing problems
- Concurrency concerns
- Better domain operations
Do not reveal the answer until you complete your own review.
26. Exercise Solution
The code has several serious encapsulation problems.
Issue 1: Public Fields
Callers can directly modify:
balance
status
transactionReferenceswithout using the service.
Issue 2: Unrestricted setBalance()
Any caller can execute:
wallet.setBalance(new BigDecimal("-999999"));No validation protects the balance.
Issue 3: Withdrawal Rule Is Outside Wallet
WalletService performs:
wallet.balance.subtract(amount)The wallet itself does not verify:
- Amount is positive.
- Wallet is active.
- Balance is sufficient.
Issue 4: Public Mutable Transaction List
This method:
getTransactionReferences()returns the internal list.
A caller can execute:
wallet.getTransactionReferences().clear();Issue 5: Generic setStatus()
Any state transition is possible.
For example:
CLOSED -> ACTIVEcould happen even if the business rules forbid it.
Issue 6: No Validation of Amount
Negative withdrawal may effectively increase the balance.
Issue 7: Concurrency Risk
Two concurrent withdrawals can potentially read the same balance and both succeed depending on transaction and locking configuration.
Encapsulation alone does not solve this, but the domain operation should at least centralize the balance rule.
Improved Wallet
@Entity
public class Wallet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long customerId;
private BigDecimal balance;
@Enumerated(EnumType.STRING)
private WalletStatus status;
@Version
private Long version;
@ElementCollection
private final List<String> transactionReferences = new ArrayList<>();
protected Wallet() {
}
public Wallet(Long customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId must not be null");
this.balance = BigDecimal.ZERO;
this.status = WalletStatus.ACTIVE;
}
public Long getId() {
return id;
}
public Long getCustomerId() {
return customerId;
}
public BigDecimal getBalance() {
return balance;
}
public WalletStatus getStatus() {
return status;
}
public List<String> getTransactionReferences() {
return List.copyOf(transactionReferences);
}
public void withdraw(BigDecimal amount, String transactionReference) {
validatePositiveAmount(amount);
ensureActive();
if (balance.compareTo(amount) < 0) {
throw new InsufficientWalletBalanceException();
}
balance = balance.subtract(amount);
transactionReferences.add(
Objects.requireNonNull(
transactionReference,
"transactionReference must not be null"
)
);
}
public void credit(BigDecimal amount, String transactionReference) {
validatePositiveAmount(amount);
if (status == WalletStatus.CLOSED) {
throw new IllegalStateException("Closed wallet cannot receive funds");
}
balance = balance.add(amount);
transactionReferences.add(
Objects.requireNonNull(
transactionReference,
"transactionReference must not be null"
)
);
}
public void freeze() {
if (status == WalletStatus.CLOSED) {
throw new IllegalStateException("Closed wallet cannot be frozen");
}
status = WalletStatus.FROZEN;
}
public void activate() {
if (status != WalletStatus.FROZEN) {
throw new IllegalStateException("Only frozen wallet can be activated");
}
status = WalletStatus.ACTIVE;
}
public void close() {
if (balance.compareTo(BigDecimal.ZERO) != 0) {
throw new IllegalStateException("Wallet balance must be zero before closing");
}
status = WalletStatus.CLOSED;
}
private void validatePositiveAmount(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
}
private void ensureActive() {
if (status != WalletStatus.ACTIVE) {
throw new IllegalStateException("Wallet must be active");
}
}
}The service now delegates state changes.
@Service
public class WalletService {
private final WalletRepository walletRepository;
public WalletService(WalletRepository walletRepository) {
this.walletRepository = walletRepository;
}
@Transactional
public void withdraw(Long walletId, BigDecimal amount) {
Wallet wallet = getWallet(walletId);
wallet.withdraw(
amount,
"WITHDRAW-" + UUID.randomUUID()
);
}
@Transactional
public void freeze(Long walletId) {
getWallet(walletId).freeze();
}
@Transactional
public void activate(Long walletId) {
getWallet(walletId).activate();
}
private Wallet getWallet(Long walletId) {
return walletRepository.findById(walletId)
.orElseThrow(() -> new WalletNotFoundException(walletId));
}
}Why These Changes Are Useful
The wallet now owns its financial rules.
A withdrawal cannot:
- Use a negative amount.
- Exceed the available balance.
- Occur on a frozen wallet.
The status cannot be changed arbitrarily.
The internal transaction list cannot be cleared by callers.
The service no longer knows how balance mutation works internally.
@Version also provides optimistic-locking support for concurrent updates, although the exact concurrency strategy must match the application's requirements.
This produces a much stronger domain boundary.
27. Interview Perspective
Encapsulation frequently appears in Java interviews as a basic OOP question:
What is encapsulation?
For experienced developers, however, a simple answer such as:
Make fields private and use getters and setters.
is incomplete.
A stronger answer should explain that encapsulation means:
- Hiding internal representation.
- Controlling state changes.
- Protecting invariants.
- Providing meaningful public behavior.
- Preventing callers from creating invalid state.
- Reducing dependency on internal implementation details.
A senior-level interviewer may ask:
If all fields are private but every field has a public getter and setter, is the class well encapsulated?
The answer is:
Not necessarily.
If important state can still be changed arbitrarily, the object does not meaningfully protect its invariants.
Another scenario may involve collections:
What is wrong with returning an internal ArrayList from a getter?
The caller receives direct access to mutable internal state.
Another common Spring/JPA question is:
Do JPA entities need public setters?
Generally, no. JPA can often work with field access and a protected no-argument constructor depending on mapping strategy.
The public API should be designed for the application's domain needs rather than exposing setters merely for ORM convenience.
28. Interview Questions and Answers
Basic Question
Question: What is encapsulation in Java?
Answer:
Encapsulation means hiding internal state and implementation details behind a controlled public API.
The object should decide how its state can change rather than allowing arbitrary external modification.
private fields are one mechanism used to achieve encapsulation, but encapsulation also involves behavior, validation, and protecting invariants.
Intermediate Question
Question: Are private fields with public getters and setters enough for encapsulation?
Answer:
Not always.
For simple DTOs, getters and setters may be appropriate.
For domain objects containing business rules, unrestricted setters can expose the same state-management problem as public fields.
For example:
account.setBalance(amount);allows callers to bypass deposit and withdrawal rules.
A better domain API may expose:
account.deposit(amount);
account.withdraw(amount);These operations can validate the state change.
Advanced Question
Question: How do you encapsulate mutable collections in Java?
Answer:
Do not normally return the internal mutable collection directly when callers should not control it.
Instead, depending on requirements, return:
List.copyOf(items)or another protected representation.
Similarly, when accepting mutable collections in constructors, consider copying them so later modifications to the caller's collection cannot affect internal state.
The exact approach should consider collection size, performance, ownership, and ORM behavior.
Scenario-Based Question
Question: An Order entity has setStatus(OrderStatus) and many services change the status directly. How would you improve it?
Answer:
I would first identify the valid order-state transitions.
Then I would expose domain operations such as:
confirm()
ship()
deliver()
cancel()Each operation would validate the current state before performing the transition.
After migrating callers, I would remove or restrict the generic status setter.
This makes invalid transitions harder to represent and centralizes the business rules.
Code-Review Question
Question: What encapsulation problems would you look for during a Pull Request review?
Answer:
I would look for:
- Public mutable fields
- Unrestricted setters
- Mutable collection getters
- Internal arrays returned directly
- Sensitive getters
- Direct state manipulation from services
- Repeated invariant checks outside the object
- Generic
setStatus()methods - Lombok
@Dataon stateful domain entities - Constructors allowing invalid initial state
- Internal implementation details exposed through public APIs
These indicate that the object may not adequately protect its own state.
Real-Project Question
Question: How does encapsulation help in Spring Boot enterprise applications?
Answer:
It keeps business rules and state transitions behind stable APIs.
For example, instead of multiple Spring services directly modifying a payment entity's fields, the entity or domain service can expose:
authorize()
capture()
refund()
fail()This reduces duplicated rules, makes state changes easier to test, limits invalid transitions, and allows internal representation to evolve without changing every service.
Persistence, HTTP, and serialization concerns can then be handled at their appropriate boundaries.
29. Quick Rule to Remember
Do not expose state merely because another class wants to change it; expose the valid operation that the business actually allows.
30. Final Takeaway
Encapsulation is not simply:
private fields + getters + settersStrong encapsulation means an object protects its internal state and exposes only operations that callers are allowed to perform.
Developers should remember:
- Keep important state private.
- Do not create unrestricted setters automatically.
- Protect business invariants.
- Use meaningful state-transition methods.
- Do not expose mutable internal collections directly.
- Use defensive copying where ownership requires it.
- Keep sensitive fields out of unnecessary getters and serialization.
- Allow domain objects to protect their own valid state where appropriate.
- Keep persistence requirements from unnecessarily weakening the public API.
- Prefer immutable value objects when mutation provides no value.
During Pull Request review, reviewers should check:
- Whether external code can create invalid object state.
- Whether setters bypass business rules.
- Whether collections or arrays expose internal mutation.
- Whether state transitions are validated.
- Whether sensitive data is unnecessarily exposed.
- Whether business invariants are duplicated across multiple services.
- Whether Lombok-generated methods widen the API unintentionally.
- Whether internal implementation details leak into callers.
- Whether constructors establish a valid initial state.
Production code should avoid designs where any caller can freely execute operations equivalent to:
setBalance(...)
setStatus(...)
setRole(...)
getInternalList().clear()without the owning object having any control.
A well-encapsulated Java component clearly defines what callers are allowed to do, protects everything they should not be able to do directly, and keeps its internal implementation free to evolve without breaking the rest of the application.