OOP Encapsulation Design in Java - hiding state and enforcing invariants
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Software Dev March 22, 2026 15 min read Clean Code Engineering Series

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

  1. The Anemic Domain Model Anti-Pattern
  2. Enforcing Class Invariants with Private Constructors
  3. Immutable Value Objects: Records vs Hand-Crafted
  4. Tell, Don't Ask: Eliminating Getter/Setter Anti-Patterns
  5. Law of Demeter: Why Chained Calls Break Encapsulation
  6. Package-Private by Default: Reducing Visibility Surface
  7. Builder vs Factory for Complex Object Construction
  8. Production Case: Data Corruption from Mutable Shared State
  9. 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);
    }
}
Key insight: With this design, it is impossible to create an Account with a negative balance or debit more than available. No service code can bypass these invariants because 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));
}
Production impact: When the approval logic lives in the Order object, all 12 code paths that previously called 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.

Rule of thumb: Any object shared across threads should be either immutable or thread-safe with explicit synchronization. In Spring Boot, singleton beans are shared across all request threads by default. If a Spring bean holds mutable state, you have a race condition waiting to happen.

For more on designing safe concurrent Java systems, see Java Structured Concurrency and Java Concurrency Patterns on this blog.

9. Key Takeaways

Read Full Blog Here

Explore the complete series on clean Java engineering at:

mdsanwarhossain.me

Related Posts

Software Dev

SOLID Principles in Java

Real-world refactoring with all five SOLID principles in Spring Boot microservices.

Core Java

Java Design Patterns in Production

Strategy, Factory, and Builder patterns with real Spring Boot production examples.

Core Java

Java Record Patterns

Exhaustive pattern matching with Java 21 sealed classes and record deconstruction.

Last updated: March 2026 — Written by Md Sanwar Hossain