Software Engineer · Java · Spring Boot · Microservices
Advanced OOP Encapsulation in Java: Hiding State, Enforcing Invariants & Designing for Change
In a real incident at a fintech company, a bug in production allowed negative balances on customer accounts — not because of a logic error in the payment service, but because the Account object exposed a public setBalance() method that was called from a dozen places without any validation. This is the hidden cost of poor encapsulation. This post dives into advanced Java encapsulation patterns that prevent exactly this class of bug and make your domain objects genuinely resilient to misuse.
Table of Contents
- The Anemic Domain Model Anti-Pattern
- Enforcing Class Invariants with Private Constructors
- Immutable Value Objects: Records vs Hand-Crafted
- Tell, Don't Ask: Eliminating Getter/Setter Anti-Patterns
- Law of Demeter: Why Chained Calls Break Encapsulation
- Package-Private by Default: Reducing Visibility Surface
- Builder vs Factory for Complex Object Construction
- Production Case: Data Corruption from Mutable Shared State
- Key Takeaways
1. The Anemic Domain Model Anti-Pattern
Martin Fowler coined the term Anemic Domain Model to describe the pattern where domain objects are essentially data bags — all fields public or exposed via getters/setters, with no behavior. The business logic lives entirely in service classes. It looks like OOP but behaves like procedural programming dressed in Java syntax.
The symptom is unmistakable: you have a CustomerService with 2,000 lines, reaching into Customer objects, reading field values, computing decisions, and writing results back. The Customer class itself is just a POJO with getters and setters. Any part of the codebase can modify any field at any time. There are no guards.
// Anemic Domain Model — anti-pattern
public class Account {
private BigDecimal balance;
private AccountStatus status;
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; } // No guard!
public AccountStatus getStatus() { return status; }
public void setStatus(AccountStatus status) { this.status = status; }
}
// In AccountService, 47 places do this:
account.setBalance(account.getBalance().subtract(amount)); // validation? nowhere.
The fix is not to add validation in the service. The fix is to move behavior into the domain object so the object itself is impossible to put into an invalid state.
2. Enforcing Class Invariants with Private Constructors
A class invariant is a condition that must be true for every valid instance of that class, from construction through its entire lifetime. The most powerful way to enforce invariants is to make the constructor private and force object creation through static factory methods that validate the invariant at the gate.
public final class Account {
private final String id;
private BigDecimal balance;
private final Currency currency;
private AccountStatus status;
private Account(String id, BigDecimal initialBalance, Currency currency) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("Account ID required");
if (initialBalance == null || initialBalance.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Initial balance cannot be negative");
this.id = id;
this.balance = initialBalance;
this.currency = currency;
this.status = AccountStatus.ACTIVE;
}
public static Account open(String id, BigDecimal initialDeposit, Currency currency) {
return new Account(id, initialDeposit, currency);
}
// Behavior method — invariant protected inside the object
public void debit(BigDecimal amount) {
if (status != AccountStatus.ACTIVE) throw new AccountNotActiveException(id);
if (amount.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Debit amount must be positive");
if (balance.compareTo(amount) < 0) throw new InsufficientFundsException(id, amount, balance);
this.balance = balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (status != AccountStatus.ACTIVE) throw new AccountNotActiveException(id);
if (amount.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Credit amount must be positive");
this.balance = balance.add(amount);
}
}
setBalance() simply doesn't exist. The compiler enforces correctness at every call site.
3. Immutable Value Objects: Java Records vs Hand-Crafted
Value objects represent descriptive aspects of the domain with no conceptual identity — a Money amount, a DateRange, an EmailAddress. They should be immutable: once created, they never change. Any "modification" produces a new instance. This eliminates an entire class of aliasing bugs where two references to the same object unexpectedly affect each other.
Java 16+ Records are the natural fit for immutable value objects, but they have a catch: the compact constructor must validate invariants:
// Java Record as immutable Value Object
public record Money(BigDecimal amount, Currency currency) {
// Compact constructor validates invariants
public Money {
if (amount == null) throw new IllegalArgumentException("Amount required");
if (currency == null) throw new IllegalArgumentException("Currency required");
if (amount.scale() > 2) throw new IllegalArgumentException("Max 2 decimal places for " + currency);
amount = amount.setScale(2, RoundingMode.HALF_EVEN); // normalize
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new CurrencyMismatchException(this.currency, other.currency);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public boolean isPositive() { return amount.compareTo(BigDecimal.ZERO) > 0; }
}
// Usage — no mutation, no aliasing bugs
Money price = new Money(new BigDecimal("29.99"), Currency.USD);
Money tax = price.multiply(new BigDecimal("0.08"));
Money total = price.add(tax); // price and tax unchanged
For cases where a Record isn't enough — e.g., when you need a superclass or custom serialization — use the hand-crafted approach with final class, final fields, deep copies in constructors for mutable members (like Date or arrays), and defensive copies in getters.
4. Tell, Don't Ask: Eliminating Getter/Setter Anti-Patterns
The Tell, Don't Ask principle states: instead of querying an object's state and then making a decision externally, tell the object to do something and let it make the decision internally. Getters are not inherently evil, but using getters solely to make decisions about an object from outside it is a smell.
// ASKING (anti-pattern) — caller makes decisions about internal state
if (order.getStatus() == OrderStatus.PENDING &&
order.getItems().size() > 0 &&
order.getCustomer().getCreditScore() > 650) {
order.setStatus(OrderStatus.APPROVED);
order.setApprovedAt(Instant.now());
}
// TELLING (encapsulated) — object owns the decision
order.approve(creditEvaluationResult); // Order checks its own preconditions
// Inside Order
public void approve(CreditEvaluationResult creditResult) {
if (status != OrderStatus.PENDING) throw new InvalidOrderStateException();
if (items.isEmpty()) throw new EmptyOrderException();
if (!creditResult.isApproved()) throw new CreditDeniedException(creditResult);
this.status = OrderStatus.APPROVED;
this.approvedAt = Instant.now();
this.domainEvents.add(new OrderApprovedEvent(id, approvedAt));
}
setStatus(APPROVED) are now replaced by order.approve(). Add a new approval rule? Change it in one place. Previously, you'd hunt across all 12 call sites.
5. Law of Demeter: Why Chained Calls Break Encapsulation
The Law of Demeter (LoD) says a method should only talk to its direct collaborators — not to the collaborators' collaborators. The telltale sign of a violation is a long chain of method calls: order.getCustomer().getAddress().getCity().toUpperCase(). This couples your code to the internal structure of Order, Customer, and Address simultaneously.
If Customer later stores addresses in a list instead of a single field, every call site with this chain breaks. The fix is a delegation method:
// Demeter violation — coupled to 3 levels of internals
String city = order.getCustomer().getAddress().getCity();
// Fixed — Order provides what callers need
public String getCustomerCity() {
return customer.getCity(); // Customer provides its own city
}
// In Customer
public String getCity() {
return primaryAddress.getCity(); // Address provides its city
}
Note that LoD is about structural coupling, not method chains. Fluent APIs and builder chains are fine — they chain calls on the same object. The problem is chaining across different object boundaries to navigate to deeply nested state.
6. Package-Private by Default: Reducing Visibility Surface
Java's default access modifier — package-private (no keyword) — is one of the most under-utilized tools for encapsulation. Most developers default to public out of habit or IDE auto-completion. But every public method is a promise to external code: "this method will exist forever, with this signature."
A disciplined visibility strategy follows this order: private → package-private → protected → public. Start as restrictive as possible and open up only when needed. For a layered Spring Boot application, this means most domain classes and their methods should be package-private within the domain package. Only the interfaces and DTOs that cross package boundaries need to be public.
// domain/payment package
class PaymentProcessor { // package-private — only visible within domain/payment
BigDecimal calculateFee(Money amount) { ... } // package-private method
}
public class PaymentService { // public — exposed to other packages
private final PaymentProcessor processor; // dependency is internal
public PaymentResult process(PaymentRequest request) {
// Uses PaymentProcessor internally — callers never know it exists
}
}
7. Builder vs Factory for Complex Object Construction
When constructing objects with many optional parameters, two encapsulation-preserving patterns shine: Static Factory Methods for objects with a small number of meaningful construction variants, and Builders for objects with many optional configuration parameters.
The builder is particularly powerful for maintaining invariants when some combinations of fields are invalid. The builder collects all parameters and only constructs the object in the build() method, where it can validate the combination atomically:
public class Notification {
private final String recipient;
private final String subject;
private final NotificationChannel channel;
private final Instant scheduledAt; // optional
private Notification(Builder b) {
this.recipient = b.recipient;
this.subject = b.subject;
this.channel = b.channel;
this.scheduledAt = b.scheduledAt;
}
public static class Builder {
private String recipient;
private String subject;
private NotificationChannel channel;
private Instant scheduledAt;
public Builder recipient(String r) { this.recipient = r; return this; }
public Builder subject(String s) { this.subject = s; return this; }
public Builder channel(NotificationChannel c) { this.channel = c; return this; }
public Builder scheduleAt(Instant t) { this.scheduledAt = t; return this; }
public Notification build() {
if (recipient == null) throw new IllegalStateException("recipient required");
if (subject == null) throw new IllegalStateException("subject required");
if (channel == null) throw new IllegalStateException("channel required");
if (channel == NotificationChannel.PUSH && scheduledAt != null
&& scheduledAt.isBefore(Instant.now()))
throw new IllegalStateException("Cannot schedule push notification in the past");
return new Notification(this);
}
}
}
8. Production Case: Data Corruption from Mutable Shared State
A mid-sized e-commerce platform used a shared PricingRule object that was loaded from the database at startup and cached in a Spring singleton bean. The object had public setters for applying promotional overrides. During a flash sale, a thread processing bulk order imports modified the shared PricingRule's discount field. All concurrent order-processing threads — handling regular customer orders — suddenly started applying the bulk import's discount to individual customers.
The incident caused $180,000 in under-charged orders before the team caught it in reconciliation 6 hours later. Root cause: a mutable singleton shared across threads with no visibility control. The fix required making PricingRule immutable and creating a new instance per promotion application rather than mutating the cached one.
For more on designing safe concurrent Java systems, see Java Structured Concurrency and Java Concurrency Patterns on this blog.
9. Key Takeaways
- Avoid the Anemic Domain Model — move behavior into domain objects to co-locate invariant enforcement with state
- Use private constructors and static factory methods to guarantee objects are always created in a valid state
- Java Records are the cleanest way to create immutable value objects; always validate in the compact constructor
- Apply the Tell, Don't Ask principle: let objects make decisions about their own state rather than exposing raw getters
- Respect the Law of Demeter — chaining across object boundaries is structural coupling in disguise
- Default to package-private visibility; widen to public only when truly needed
- Any mutable object shared across threads is a data corruption incident waiting to happen
Related Posts
SOLID Principles in Java
Real-world refactoring with all five SOLID principles in Spring Boot microservices.
Java Design Patterns in Production
Strategy, Factory, and Builder patterns with real Spring Boot production examples.
Java Record Patterns
Exhaustive pattern matching with Java 21 sealed classes and record deconstruction.
Last updated: March 2026 — Written by Md Sanwar Hossain