Encapsulation

25 min read

Object-Oriented Design and SOLID Review — review Java classes for exposed internal state and missing access control that weakens invariants and increases fragility.

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:

JAVA
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:

JAVA
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.

JAVA
public class CustomerAccount {
    public BigDecimal balance;
    public AccountStatus status;
}

Any caller can do:

JAVA
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:

JAVA
public class CustomerAccount {
    private BigDecimal balance;
    private AccountStatus status;
}

However, adding unrestricted setters may bring the same problem back:

JAVA
public void setBalance(BigDecimal balance) {
    this.balance = balance;
}

public void setStatus(AccountStatus status) {
    this.status = status;
}

Now any caller can still execute:

JAVA
account.setBalance(new BigDecimal("-500000"));

Real encapsulation is stronger.

The class should expose meaningful behavior:

JAVA
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:

JAVA
private BigDecimal totalAmount;

Later, the implementation may calculate the total from order items.

If callers use:

JAVA
order.getTotalAmount();

the internal representation can change with limited impact.

Reliability

Encapsulated objects prevent invalid state transitions.

For example:

JAVA
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:

JAVA
setStatus(...)

through dozens of classes, reviewers can inspect:

JAVA
approve()
cancel()
complete()
refund()

Readability

Business methods communicate intent.

Compare:

JAVA
order.setStatus(OrderStatus.CANCELLED);

with:

JAVA
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:

JAVA
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 private unless 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 final for references that should not be reassigned.
  • Keep helper methods private when 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() and withdraw() over generic setters.
  • Use immutable value objects when mutation is unnecessary.
  • Review generated Lombok setters carefully rather than adding @Data automatically.
  • 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.

JAVA
@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.

JAVA
@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:

JAVA
totalAmount
status
items

without validation.

Business Rules Are Outside the Object

The service is responsible for remembering how to update:

JAVA
items
totalAmount

together.

Another developer may add an item without recalculating the total.

Invalid State Is Easy to Create

Any caller can execute:

JAVA
order.status = OrderStatus.DELIVERED;

even when the order has never been shipped.

Arbitrary Financial Modification

This method is dangerous:

JAVA
changeTotal(Long orderId, BigDecimal amount)

It allows callers to set any total directly.

Mutable Collection Exposure

The items list is publicly accessible.

Callers can:

JAVA
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:

Order currently exposes mutable fields publicly, so callers can bypass business validation. Could we make the state private and expose operations such as addItem() and cancel() that preserve the order invariants?

Another:

Returning or exposing the mutable items collection 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.

JAVA
@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.

JAVA
@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:

JAVA
confirm()
cancel()

instead of:

JAVA
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:

JAVA
item total != order total

Collection Is Protected

getItems() returns:

JAVA
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

AreaBad CodeImproved Code
Field accessPublic mutable fieldsPrivate state
Status changesDirect assignmentControlled operations
Order totalArbitrary mutationDerived internally
CollectionsDirect mutable accessProtected view
Business rulesSpread across servicesProtected near state
MaintainabilityCallers know internalsInternals can evolve
TestabilityMust test external mutation pathsBehavior-focused tests
ReliabilityInvalid states easy to createInvalid transitions rejected
ReadabilityGeneric mutationsIntent-revealing operations

13. Real Project Scenario

Consider a banking system containing:

JAVA
BankAccount

The initial implementation exposes:

JAVA
setBalance()
setStatus()
setDailyWithdrawalLimit()

Multiple services use these setters directly.

A withdrawal service does:

JAVA
account.setBalance(
    account.getBalance().subtract(amount)
);

A scheduled-fee job does:

JAVA
account.setBalance(
    account.getBalance().subtract(fee)
);

An adjustment API does:

JAVA
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:

JAVA
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:

JAVA
order = DELIVERED
payment = FAILED

Financial 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:

JAVA
items updated
totalAmount not updated

Security 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:

JAVA
private BigDecimal balance;

public void setBalance(BigDecimal balance) {
    this.balance = balance;
}

still exposes unrestricted state mutation.

Mistake 2: Lombok @Data on Domain Entities

Using:

JAVA
@Data

may generate setters for every field.

That can expose fields that should be controlled.

Use Lombok deliberately.

Mistake 3: Returning Mutable Collections

Example:

JAVA
public List<OrderItem> getItems() {
    return items;
}

A caller can modify internal state.

Mistake 4: Saving Mutable Input Collections Directly

Example:

JAVA
this.roles = roles;

If the caller later executes:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
LocalDate
LocalDateTime
Instant

over older mutable date representations.

Arrays

Returning an internal array directly exposes mutable state.

Bad:

JAVA
public byte[] getDocument() {
    return document;
}

Safer where needed:

JAVA
public byte[] getDocument() {
    return document.clone();
}

Collections

Use defensive copying carefully.

For example:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
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.

JAVA
@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.

JAVA
@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.

JAVA
@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:

JAVA
public fields
package-visible mutable fields
direct collection mutation

Step 2: Identify Dangerous Setters

Look for methods such as:

JAVA
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:

JAVA
withdraw()
cancel()
refund()
activate()
addItem()

Step 5: Protect Collections

Replace direct mutable exposure with:

JAVA
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 final where 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:

JAVA
public BigDecimal balance;

Setter for Every Field

Avoid generating unrestricted setters automatically for business entities.

Generic State Setter

Avoid:

JAVA
setStatus(...)

when status represents a controlled workflow.

Mutable Collection Getter

Avoid:

JAVA
return internalList;

Internal Collection Reference Assignment

Avoid:

JAVA
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 @Data generating 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

  1. 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()?
  1. getItems() currently returns the internal mutable list. A caller can modify the order without going through validation. Please return a protected view or copy.
  1. Setting totalAmount directly can make it inconsistent with the order items. Consider deriving the total or updating it only through controlled item operations.
  1. @Data will generate setters for all fields, including fields that appear to represent protected domain state. Could we use targeted Lombok annotations instead?
  1. This method stores the caller's mutable list directly. Please consider making a defensive copy so later caller changes cannot modify internal state.
  1. The service repeats the same account-balance validation used in other workflows. This invariant may be safer inside the account's debit/withdraw operation.
  1. The passwordHash getter exposes sensitive internal state to every consumer of this entity. Is this getter actually required?
  1. Changing the order status through setStatus() does not verify whether the transition is legal. Please keep the transition logic behind a domain operation.
  1. This private helper does not appear to be needed outside the class. Please keep its visibility private rather than expanding the public API.
  1. 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.

JAVA
@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:

JAVA
balance
status
transactionReferences

without using the service.

Issue 2: Unrestricted setBalance()

Any caller can execute:

JAVA
wallet.setBalance(new BigDecimal("-999999"));

No validation protects the balance.

Issue 3: Withdrawal Rule Is Outside Wallet

WalletService performs:

JAVA
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:

JAVA
getTransactionReferences()

returns the internal list.

A caller can execute:

JAVA
wallet.getTransactionReferences().clear();

Issue 5: Generic setStatus()

Any state transition is possible.

For example:

JAVA
CLOSED -> ACTIVE

could 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

JAVA
@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.

JAVA
@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:

JAVA
account.setBalance(amount);

allows callers to bypass deposit and withdrawal rules.

A better domain API may expose:

JAVA
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:

JAVA
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:

JAVA
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 @Data on 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:

JAVA
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:

JAVA
private fields + getters + setters

Strong 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:

JAVA
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.