Loading learning content...
Making fields private is only half the story. Once state is hidden, you must design controlled access mechanisms—the carefully crafted gateways through which the outside world reads and modifies your object's data.
These mechanisms are not mere syntax conveniences. They are design decisions with profound implications for maintainability, correctness, and evolvability. A poorly designed accessor can violate encapsulation just as thoroughly as a public field. A well-designed accessor protects invariants, expresses intent, and preserves flexibility.
This page explores the art and science of state access: when to allow reading, when to allow writing, how to design access methods that serve clients while protecting your object's integrity.
By the end of this page, you'll understand the spectrum of access patterns from full exposure to complete encapsulation, when getters are appropriate and when they indicate design smell, why setters are almost always problematic, and how to design command methods that modify state safely.
Not all state access is equal. There's a spectrum from fully open (public fields) to completely hidden (no external access at all). Understanding this spectrum helps you choose the right level of access for each piece of state.
| Level | Pattern | Encapsulation | Use Case |
|---|---|---|---|
| 1 - Fully Exposed | Public field | None | Almost never—constants only |
| 2 - Read-Write Access | Getter + Setter | Minimal | Configuration properties, DTOs |
| 3 - Read-Only Access | Getter only | Moderate | Computed properties, status flags |
| 4 - Write-Only Access | Setter or command | Moderate | Passwords, tokens (rarely appropriate) |
| 5 - Controlled Modification | Command methods | High | Domain objects with invariants |
| 6 - Fully Hidden | No external access | Complete | Pure implementation details |
The Key Insight: Moving down this spectrum increases encapsulation. The goal isn't to always be at level 6—sometimes clients genuinely need to read or modify state. The goal is to consciously choose the most restrictive level that still serves client needs.
Let's examine each pattern in detail to understand when each is appropriate.
When uncertain, start with no access (level 6). You can always add a getter later if clients need it. You can never take access away once it's granted—at least not without breaking client code.
Getters seem harmless—they're read-only, so how can they violate encapsulation? Yet careless getters are a leading cause of encapsulation breakdown.
The problem isn't that getters exist; it's that they're used reflexively, without thought. Every getter you add exposes part of your internal state to the world. Clients start depending on that state. Now you can't change your internal representation without breaking them.
The Getter Litmus Test:
Does the client need this data to make decisions, or should the object be making that decision itself?
If the object should be making the decision, you don't need a getter—you need a method.
if (order.getStatus() == PAID) → use order.isPaid()cart.getItems() → exposes internal structure12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ❌ POOR: Client extracts data and makes decisionspublic class OrderProcessor { public void processOrder(Order order) { // Feature envy: pulling data out to make decisions if (order.getStatus() == OrderStatus.PENDING) { if (order.getTotal().compareTo(order.getCustomer().getCreditLimit()) > 0) { if (order.getItems().stream() .anyMatch(item -> !item.getProduct().isInStock())) { order.setStatus(OrderStatus.BACKORDERED); } else { order.setStatus(OrderStatus.CREDIT_HOLD); } } else { order.setStatus(OrderStatus.APPROVED); } } }} // ✅ GOOD: Ask the object to make its own decisionspublic class Order { private OrderStatus status; private BigDecimal total; private Customer customer; private List<OrderItem> items; public void approve() { validatePendingState(); if (exceedsCreditLimit()) { status = OrderStatus.CREDIT_HOLD; } else if (hasBackorderedItems()) { status = OrderStatus.BACKORDERED; } else { status = OrderStatus.APPROVED; } } public boolean isPending() { return status == OrderStatus.PENDING; } public boolean isApproved() { return status == OrderStatus.APPROVED; } private boolean exceedsCreditLimit() { return total.compareTo(customer.getCreditLimit()) > 0; } private boolean hasBackorderedItems() { return items.stream() .anyMatch(item -> !item.isInStock()); } private void validatePendingState() { if (!isPending()) { throw new IllegalStateException("Can only approve pending orders"); } }} // Now the client is simple:public class OrderProcessor { public void processOrder(Order order) { if (order.isPending()) { order.approve(); } }}If you find yourself getting data from an object just to tell that object what to do, the logic belongs inside the object. Instead of 'get data, make decision, call method,' let the object 'make decision internally.' This keeps business logic with the data it operates on.
If getters are sometimes problematic, setters are almost always problematic. A setter is an invitation for any code anywhere to modify your object's state, bypassing any checks, validations, or coordinated updates you might need.
Why Setters Violate Encapsulation:
1234567891011121314151617181920212223242526
// ❌ DANGEROUS: Setters allow invalid state combinationspublic class Employee { private String firstName; private String lastName; private BigDecimal salary; private Department department; private Manager manager; private EmployeeLevel level; // Each setter is an independent entry point to state modification public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setSalary(BigDecimal salary) { this.salary = salary; } public void setDepartment(Department dept) { this.department = dept; } public void setManager(Manager manager) { this.manager = manager; } public void setLevel(EmployeeLevel level) { this.level = level; }} // Client code can create INVALID state combinations:employee.setDepartment(engineering);employee.setManager(salesManager); // Manager from wrong department!employee.setLevel(EmployeeLevel.DIRECTOR);employee.setSalary(new BigDecimal("50000")); // Below director minimum! // Each setter works individually, but the combination is invalid.// The class cannot protect its invariants.When Setters Are Acceptable:
Setters are appropriate only in narrow circumstances:
build() validates everythingIf you find yourself writing a setter, stop and ask: 'What operation is the client actually performing?' Usually there's a more meaningful domain operation hiding behind the setter. Instead of setStatus(CANCELLED), write cancel(). Instead of setBalance(newBalance), write deposit(amount) or withdraw(amount).
The alternative to setters is command methods—methods that express meaningful operations and modify state as a side effect of those operations.
Command methods differ from setters in crucial ways:
| Setter | Command Method |
|---|---|
setStatus(Status s) | approve(), reject(), cancel() |
setBalance(amount) | deposit(amount), withdraw(amount) |
setLocation(loc) | moveTo(destination), relocate(newOffice) |
setIsActive(false) | deactivate(), suspend(), archive() |
The difference is semantic: Setters say "change this field to this value." Command methods say "perform this operation." The operation may change the field, but the client doesn't need to know what fields exist.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// ✅ GOOD: Command methods express domain operationspublic class Employee { private String name; private BigDecimal salary; private Department department; private Manager manager; private EmployeeLevel level; private boolean isActive; // No setters! Only meaningful operations. public void promote(EmployeeLevel newLevel, BigDecimal newSalary) { validatePromotionPath(newLevel); validateSalaryForLevel(newLevel, newSalary); this.level = newLevel; this.salary = newSalary; recordPromotionEvent(); } public void transferTo(Department newDepartment, Manager newManager) { validateManagerInDepartment(newManager, newDepartment); Department oldDepartment = this.department; this.department = newDepartment; this.manager = newManager; notifyDepartmentChange(oldDepartment, newDepartment); } public void giveRaise(BigDecimal percentage) { validateRaisePercentage(percentage); BigDecimal increase = salary.multiply(percentage); salary = salary.add(increase); recordRaiseEvent(percentage, increase); } public void terminate(TerminationReason reason) { if (!isActive) { throw new IllegalStateException("Employee already inactive"); } isActive = false; manager = null; // Coordinated update! recordTerminationEvent(reason); } // State queries (not getters of raw fields) public boolean isEligibleForPromotion() { return isActive && level.hasNextLevel() && hasMinimumTenure(); } public boolean canTransferTo(Department dept) { return isActive && dept.hasOpenPositions(level); }}Even when read access is appropriate, how you provide that access matters enormously. Let's examine the patterns for safe state reading.
Pattern 1: Primitive Getters (Safe)
For immutable primitives and strings, simple getters are safe:
123456789101112
public class Product { private final String id; // Immutable private final String name; // Immutable private int quantity; // Primitive private BigDecimal price; // Immutable // ✅ Safe: primitives and immutable types public String getId() { return id; } public String getName() { return name; } public int getQuantity() { return quantity; } public BigDecimal getPrice() { return price; } // BigDecimal is immutable}Pattern 2: Defensive Copies (Essential for Mutable Objects)
For mutable objects like Date, List, or custom objects, you must return copies:
12345678910111213141516171819202122232425262728293031323334353637
public class Event { private Date startTime; // Mutable! private List<Attendee> attendees; // Mutable! private Address location; // Mutable! // ❌ DANGEROUS: Returns internal reference public Date getStartTimeUnsafe() { return startTime; // Client can modify: event.getStartTime().setTime(0) } // ✅ SAFE: Returns defensive copy public Date getStartTime() { return new Date(startTime.getTime()); } // ❌ DANGEROUS: Returns internal list public List<Attendee> getAttendeesUnsafe() { return attendees; // Client can: event.getAttendees().clear() } // ✅ SAFE: Returns unmodifiable view public List<Attendee> getAttendees() { return Collections.unmodifiableList(attendees); } // ✅ EVEN SAFER: Returns deep copy for fully mutable contents public List<Attendee> getAttendeesSnapshot() { return attendees.stream() .map(Attendee::copy) .collect(Collectors.toList()); } // ✅ SAFE: Returns copy of mutable object public Address getLocation() { return location.copy(); // Address has copy() method }}Pattern 3: Query Methods Instead of Raw Getters
Often, clients don't need the raw value—they need to answer a question. Provide query methods instead:
123456789101112131415161718192021222324252627282930313233343536373839404142
public class Subscription { private LocalDate expirationDate; private SubscriptionTier tier; private Set<Feature> features; private int usageThisMonth; private int monthlyLimit; // ❌ RAW GETTER APPROACH - Client does all the logic public LocalDate getExpirationDate() { return expirationDate; } public int getUsageThisMonth() { return usageThisMonth; } public int getMonthlyLimit() { return monthlyLimit; } // Client code: // if (sub.getExpirationDate().isBefore(LocalDate.now())) { ... } // if (sub.getUsageThisMonth() >= sub.getMonthlyLimit()) { ... } // ✅ QUERY METHOD APPROACH - Object answers questions public boolean isExpired() { return expirationDate.isBefore(LocalDate.now()); } public boolean isActive() { return !isExpired() && tier != SubscriptionTier.CANCELLED; } public boolean hasExceededLimit() { return usageThisMonth >= monthlyLimit; } public boolean canUseFeature(Feature feature) { return isActive() && features.contains(feature) && !hasExceededLimit(); } public int remainingUsage() { return Math.max(0, monthlyLimit - usageThisMonth); } // Client code: // if (sub.isExpired()) { ... } // Much cleaner! // if (sub.hasExceededLimit()) { ... } // if (sub.canUseFeature(Feature.EXPORT)) { ... }}Query methods like isExpired() hide whether expiration is stored as a date, calculated on demand, or fetched from a remote service. The client just asks the question; the object decides how to answer it. This freedom to change implementation is the essence of encapsulation.
When designing access to a piece of state, use this decision matrix to choose the appropriate pattern:
| Question | If Yes... | If No... |
|---|---|---|
| Does anyone outside this class need this data? | Continue to next question | Keep it private, no accessor |
| Is it for display/logging only? | Read-only getter (consider query method) | Continue to next question |
| Can the data be represented as an answer to a question? | Query method (isActive, canProcess) | Continue to next question |
| Is the internal type safe to expose? | Getter returning the type | Getter returning defensive copy or interface |
| Must external code be able to change it? | Command method for the operation | Read-only access only |
| Are there business rules around changes? | Command method with validation | Consider if setter is truly needed |
| Are multiple fields updated together? | Single command method updating all | Separate accessors if truly independent |
The Pattern Selection Flowchart:
Does client need data at all?
├── No → Keep private (no accessor)
└── Yes → For display/serialization only?
├── Yes → Answer a question?
│ ├── Yes → Query method (is/has/can)
│ └── No → Getter (defensive copy if mutable)
└── No → Client needs to modify?
├── Yes → What operation are they performing?
│ └── Create domain command method
└── No → Read-only getter
The central insight: Start by asking what the client is trying to accomplish, not what data they're trying to access. Often, the right design eliminates the need for direct data access entirely.
Let's examine a complete, real-world example that demonstrates all the access patterns working together.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
/** * A well-encapsulated Order class demonstrating proper access control. * Notice how different types of state get different access patterns. */public class Order { // ===== IDENTITY (Immutable, read-only access) ===== private final String orderId; private final String customerId; private final Instant createdAt; // ===== CORE STATE (Private, modified only by commands) ===== private OrderStatus status; private final List<OrderLine> lines; private BigDecimal subtotal; private BigDecimal tax; // ===== DERIVED STATE (Calculated, exposed via queries) ===== // No fields - computed on demand // ===== AUDIT STATE (Fully private) ===== private Instant lastModifiedAt; private int revisionNumber; public Order(String customerId) { this.orderId = UUID.randomUUID().toString(); this.customerId = customerId; this.createdAt = Instant.now(); this.status = OrderStatus.DRAFT; this.lines = new ArrayList<>(); this.subtotal = BigDecimal.ZERO; this.tax = BigDecimal.ZERO; this.lastModifiedAt = Instant.now(); this.revisionNumber = 1; } // ===== IDENTITY GETTERS (Safe - immutable values) ===== public String getOrderId() { return orderId; } public String getCustomerId() { return customerId; } public Instant getCreatedAt() { return createdAt; } // Instant is immutable // ===== QUERY METHODS (Answer questions, hide implementation) ===== public boolean isDraft() { return status == OrderStatus.DRAFT; } public boolean isSubmitted() { return status == OrderStatus.SUBMITTED; } public boolean isShipped() { return status == OrderStatus.SHIPPED; } public boolean isCancellable() { return status == OrderStatus.DRAFT || status == OrderStatus.SUBMITTED; } public boolean isEmpty() { return lines.isEmpty(); } public int getItemCount() { return lines.size(); } // ===== CALCULATED GETTERS (Derived values) ===== public BigDecimal getTotal() { return subtotal.add(tax); } public BigDecimal getSubtotal() { return subtotal; } public BigDecimal getTax() { return tax; } // ===== SAFE COLLECTION ACCESS (Unmodifiable view) ===== public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } // ===== COMMAND METHODS (Meaningful operations) ===== public void addLine(Product product, int quantity) { requireDraftStatus(); validateQuantity(quantity); lines.add(new OrderLine(product, quantity)); recalculateTotals(); markModified(); } public void removeLine(String lineId) { requireDraftStatus(); boolean removed = lines.removeIf(line -> line.getId().equals(lineId)); if (!removed) { throw new IllegalArgumentException("Line not found: " + lineId); } recalculateTotals(); markModified(); } public void submit() { requireDraftStatus(); if (lines.isEmpty()) { throw new IllegalStateException("Cannot submit empty order"); } status = OrderStatus.SUBMITTED; markModified(); // Could publish OrderSubmittedEvent here } public void cancel(String reason) { if (!isCancellable()) { throw new IllegalStateException("Order cannot be cancelled in state: " + status); } status = OrderStatus.CANCELLED; markModified(); // Could publish OrderCancelledEvent here } // ===== PRIVATE HELPERS (Pure implementation details) ===== private void requireDraftStatus() { if (status != OrderStatus.DRAFT) { throw new IllegalStateException("Order must be in DRAFT status"); } } private void validateQuantity(int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("Quantity must be positive"); } } private void recalculateTotals() { subtotal = lines.stream() .map(OrderLine::getLineTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); tax = subtotal.multiply(TAX_RATE); } private void markModified() { lastModifiedAt = Instant.now(); revisionNumber++; } private static final BigDecimal TAX_RATE = new BigDecimal("0.08");}This Order class has identity (always readable), queries (answer questions without exposing structure), calculated values (derived on demand), collection access (unmodifiable view), commands (meaningful operations), and pure private state (audit data). Each layer uses the appropriate access pattern for its purpose.
We've explored the spectrum of state access patterns—from raw exposure to complete encapsulation. Here are the essential principles:
What's Next:
Now that we understand how to control access to state, we'll explore how to prevent invalid state—the techniques for ensuring objects can never be put into inconsistent, corrupted, or illogical states. This is where encapsulation delivers its most important benefit: self-protecting objects that maintain their own integrity.
You now understand the full spectrum of state access patterns. The key insight: accessors aren't just syntactic sugar over public fields—they're design decisions that determine how much implementation freedom you retain and how well your objects protect their invariants.