Loading content...
In object-oriented programming, objects are not merely data containers—they are living entities that combine state (data) with behavior (methods). But this raises a fundamental architectural question: How should the outside world interact with an object's internal state?
The answer lies in accessors—specialized methods that provide controlled access to an object's private fields. Commonly known as getters and setters, these methods are far more than simple conveniences. They represent a deliberate design decision about how your objects communicate with the rest of the system.
This page explores the deep purpose behind accessors, why they are essential to proper encapsulation, and how understanding their true role transforms the way you design classes.
By the end of this page, you will understand why accessors exist beyond simple syntax, how they enable encapsulation, and the difference between accessors as implementation choice versus accessors as design intent. You'll see how proper use of accessors protects object integrity and enables evolution of internal representations without breaking external code.
To understand why accessors exist, we must first understand the problem they solve. Consider a world without them—a world where all object fields are public and directly accessible.
The Direct Access Problem:
Imagine a BankAccount class with a public balance field. Any code in the system can directly read or write this field:
1234567891011121314151617181920
// Dangerous: Public field allows uncontrolled accessclass BankAccount { public double balance; // Anyone can modify this! public String accountNumber; public BankAccount(String accountNumber, double initialBalance) { this.accountNumber = accountNumber; this.balance = initialBalance; }} // Anywhere in the codebase...BankAccount account = new BankAccount("12345", 1000.0); // Direct manipulation - no validation!account.balance = -500.0; // Negative balance? Allowed!account.balance = Double.MAX_VALUE; // Infinite money? Sure! // External code depends on internal structuredouble currentBalance = account.balance * 0.02; // Tight couplingThis approach creates multiple severe problems:
1. No Validation Possible
With direct field access, there is no place to insert validation logic. If business rules require that balances never go negative, or that deposits must be audited, there is no mechanism to enforce these constraints. Any code can violate invariants at will.
2. No Encapsulation of Internal Representation
When external code directly accesses balance, it becomes coupled to the specific representation of balance as a double. If you later need to change this to a BigDecimal for precision, or to a Money value object, every line of code that accessed account.balance must change.
3. No Opportunity for Side Effects
Real-world operations often have side effects: logging, caching, notification, synchronization. With direct field access, there is no place to insert these behaviors. Every access is raw and unmediated.
4. No Debugging or Monitoring
When something goes wrong—a balance mysteriously changes—there is no centralized point to set a breakpoint or add logging. The modification could have happened from anywhere in the codebase.
5. Immutability is Impossible
If the field is public, it can be modified. There is no way to create a read-only view of the object's state. Immutability, one of the most powerful tools for building reliable software, becomes unachievable.
Public fields are not just 'bad style'—they fundamentally undermine the ability to maintain invariants, evolve implementations, and reason about code. Accessors exist to restore control over how objects interact with the world.
Accessors are methods that provide controlled access to an object's internal state. They come in two primary forms:
Getters (Accessor Methods)
A getter retrieves the value of a private field, optionally transforming, computing, or validating before returning. The naming convention is getFieldName() (or isFieldName() for booleans in Java) or simply fieldName in languages that support property syntax.
Setters (Mutator Methods)
A setter modifies the value of a private field, with the opportunity to validate input, enforce invariants, trigger side effects, or reject invalid values. The naming convention is setFieldName(value).
Let's see how accessors transform our problematic BankAccount:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
class BankAccount { // Private field - inaccessible from outside private double balance; private final String accountNumber; private final List<Transaction> transactionHistory; public BankAccount(String accountNumber, double initialBalance) { if (initialBalance < 0) { throw new IllegalArgumentException( "Initial balance cannot be negative" ); } this.accountNumber = accountNumber; this.balance = initialBalance; this.transactionHistory = new ArrayList<>(); } // Getter: controlled read access public double getBalance() { return balance; } // Getter for read-only field public String getAccountNumber() { return accountNumber; } // Getter returning defensive copy public List<Transaction> getTransactionHistory() { return Collections.unmodifiableList(transactionHistory); } // Setter with validation and side effects public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException( "Deposit amount must be positive" ); } this.balance += amount; this.transactionHistory.add( new Transaction(TransactionType.DEPOSIT, amount) ); } // Setter with complex business rules public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException( "Withdrawal amount must be positive" ); } if (amount > balance) { throw new InsufficientFundsException( "Cannot withdraw " + amount + " with balance " + balance ); } this.balance -= amount; this.transactionHistory.add( new Transaction(TransactionType.WITHDRAWAL, amount) ); }}Notice several critical differences from the public field approach:
1. The balance field is private — No external code can directly modify it. All modifications must go through the defined methods.
2. No setBalance() method exists — This is intentional! Balance should only change through deposit() and withdraw(), which encode the business operations. Direct setting of balance is not a valid operation.
3. Getters return controlled views — getTransactionHistory() returns an unmodifiable list. External code can read the history but cannot tamper with it.
4. Business rules are centralized — All validation happens in one place. If rules change, only these methods need modification.
5. Operations are named for their intent — Instead of generic setBalance(), we have deposit() and withdraw() which express domain semantics.
Think of accessors not as 'field wrappers' but as contracts between your object and the outside world. A getter promises to provide certain information. A setter (or domain method) promises to accept certain modifications under certain conditions. The internal implementation can change as long as the contract is honored.
Accessors serve four fundamental purposes in object-oriented design. Understanding each purpose helps you use accessors intentionally rather than habitually.
double to BigDecimal without breaking clients12345678910111213141516171819202122232425262728
class Circle { private double radius; // Stored field - simple getter public double getRadius() { return radius; } // Computed property - no need to store area public double getArea() { return Math.PI * radius * radius; } // Computed property - no need to store circumference public double getCircumference() { return 2 * Math.PI * radius; } // Future: could cache area if called frequently // External code wouldn't need to change} // Client code doesn't know (or care) which values// are stored vs computedCircle c = new Circle(5.0);System.out.println(c.getRadius()); // StoredSystem.out.println(c.getArea()); // ComputedSystem.out.println(c.getCircumference()); // Computed123456789101112131415161718192021222324252627282930313233343536373839
class Person { private String email; private int age; private LocalDate birthDate; public void setEmail(String email) { // Validation: must be valid email format if (email == null || !email.matches("^[\\w.]+@[\\w.]+$")) { throw new IllegalArgumentException( "Invalid email format: " + email ); } this.email = email.toLowerCase(); // Normalize } public void setAge(int age) { // Validation: must be reasonable age if (age < 0 || age > 150) { throw new IllegalArgumentException( "Age must be between 0 and 150: " + age ); } this.age = age; // Invariant maintenance: update birth date estimate this.birthDate = LocalDate.now().minusYears(age); } public void setBirthDate(LocalDate birthDate) { Objects.requireNonNull(birthDate, "Birth date cannot be null"); if (birthDate.isAfter(LocalDate.now())) { throw new IllegalArgumentException( "Birth date cannot be in the future" ); } this.birthDate = birthDate; // Invariant maintenance: update age this.age = Period.between(birthDate, LocalDate.now()).getYears(); }}123456789101112131415161718192021222324252627
class Observable { private List<PropertyChangeListener> listeners = new ArrayList<>(); private String status; public void setStatus(String newStatus) { String oldStatus = this.status; this.status = newStatus; // Side effect 1: Logging logger.info("Status changed from {} to {}", oldStatus, newStatus); // Side effect 2: Observer notification PropertyChangeEvent event = new PropertyChangeEvent( this, "status", oldStatus, newStatus ); for (PropertyChangeListener listener : listeners) { listener.propertyChange(event); } // Side effect 3: Mark for persistence this.dirty = true; } public void addPropertyChangeListener(PropertyChangeListener l) { listeners.add(l); }}123456789101112131415161718192021222324252627282930313233343536
class ThreadSafeCounter { private final AtomicInteger count = new AtomicInteger(0); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private List<String> events = new ArrayList<>(); // Thread-safe getter using atomic public int getCount() { return count.get(); } // Thread-safe increment public void increment() { count.incrementAndGet(); } // Thread-safe read with read lock public List<String> getEvents() { lock.readLock().lock(); try { return new ArrayList<>(events); // Defensive copy } finally { lock.readLock().unlock(); } } // Thread-safe write with write lock public void addEvent(String event) { lock.writeLock().lock(); try { events.add(event); } finally { lock.writeLock().unlock(); } }}Some developers argue that simple value objects don't need accessors—public fields are simpler. This argument deserves careful examination.
| Aspect | Public Fields | Accessors |
|---|---|---|
| Validation | ❌ Impossible | ✅ Can validate every change |
| Representation change | ❌ Breaks all clients | ✅ Internal only, clients unaffected |
| Computed values | ❌ Must be stored | ✅ Can compute on demand |
| Side effects | ❌ No mechanism | ✅ Can add logging, notification, etc. |
| Thread safety | ❌ No control | ✅ Can add synchronization |
| Read-only access | ❌ Impossible | ✅ Provide getter without setter |
| Debugging | ❌ No breakpoint location | ✅ Single place to intercept |
| Lazy initialization | ❌ Not possible | ✅ Initialize on first access |
| Binary compatibility | ❌ Field changes break compiled code | ✅ Method signatures stable |
| API evolution | ❌ Locked to initial design | ✅ Implementation can evolve |
The Binary Compatibility Problem:
In compiled languages like Java, public fields become part of the binary interface. If you compile code against a class with a public field, and later that class changes the field's type or removes it, the compiled code breaks even if source-level compatibility exists.
With accessors, you maintain binary compatibility because method signatures are stable. You can completely rewrite the internal implementation, and code compiled against older versions continues to work.
When Public Fields Are Acceptable:
There are limited cases where public fields are reasonable:
Private nested classes — When a class is only used internally by its outer class, the outer class already controls all access.
Package-private classes — Classes with default (package) visibility are internal implementation details.
Truly immutable simple data carriers — In modern Java (with records) or Kotlin (with data classes), compiler-generated accessors provide the benefits without manual boilerplate.
Constants — public static final fields are effectively constants and are safe.
For any public API, accessors are the only defensible choice.
"But getters and setters are just extra typing for the same thing." This is false. A public field and a getter/setter pair are fundamentally different contracts. The field says: 'Here is my implementation, use it directly.' The accessor says: 'Here is a service I provide, how I provide it may change.'
A crucial insight for understanding accessors is that they are methods first, not field wrappers. This distinction changes how you think about them.
Consider the difference between these two conceptual models:
balancegetBalance()setBalance()getBalance()The Interface-First Approach:
When designing accessors, start by asking: What does this object need to communicate to the outside world? The answer determines your getters.
Then ask: What operations can legitimately modify this object's state? The answer determines your mutator methods—which may or may not be simple setters.
This approach has profound implications:
1. Not every field needs a getter. Internal implementation details should remain hidden.
2. Not every field needs a setter. Many fields should be set-once at construction time.
3. Mutators should be domain operations. Instead of setBalance(), provide deposit() and withdraw().
4. Getters can return computed values. The absence of a corresponding field is an implementation detail.
5. The public interface is stable. Internal changes don't ripple to clients.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Interface-First: Define what the object should dointerface ShoppingCart { // What can this object tell us? int getItemCount(); Money getSubtotal(); Money getTax(); Money getTotal(); List<CartItem> getItems(); // What operations can modify it? void addItem(Product product, int quantity); void removeItem(String productId); void updateQuantity(String productId, int newQuantity); void applyCoupon(String couponCode); void clear(); // Note: No setSubtotal(), setTax(), setTotal() // These are computed values, not settable state} // Implementation can change independentlyclass DefaultShoppingCart implements ShoppingCart { private Map<String, CartItem> items = new HashMap<>(); private List<Coupon> appliedCoupons = new ArrayList<>(); private TaxCalculator taxCalculator; @Override public Money getTotal() { // Computed dynamically return getSubtotal() .add(getTax()) .subtract(getDiscounts()); } @Override public void addItem(Product product, int quantity) { // Complex logic hidden behind simple interface String key = product.getId(); if (items.containsKey(key)) { items.get(key).incrementQuantity(quantity); } else { items.put(key, new CartItem(product, quantity)); } invalidateCache(); // Internal detail } // ... other methods}Modern languages have evolved to make accessors more elegant while preserving their power. Understanding these mechanisms helps you write cleaner code while maintaining encapsulation.
123456789101112131415161718192021222324252627
// Traditional Java - verbose but explicitclass Person { private String name; private int age; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { if (age < 0) throw new IllegalArgumentException(); this.age = age; }} // Java 14+ Records - immutable with built-in accessorsrecord Point(int x, int y) { // Automatically has x() and y() accessors // No setters - records are immutable // Can add custom validation in compact constructor public Point { if (x < 0 || y < 0) { throw new IllegalArgumentException("Coordinates must be non-negative"); } }}Regardless of syntax—whether you write person.age (Python, C#, Kotlin) or person.getAge() (Java)—the underlying semantics are the same: a method is invoked, providing the opportunity for validation, computation, and side effects. Modern syntax makes this more pleasant but doesn't change the fundamental nature of accessors.
Understanding the purpose of accessors helps avoid common misuses. Here are patterns to recognize and avoid:
return Collections.unmodifiableList(items);setStartDate() and setEndDate() where start must be before endsetDateRange(start, end) that validates the relationship1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ❌ BAD: Returning mutable internal stateclass Order { private List<Item> items = new ArrayList<>(); public List<Item> getItems() { return items; // External code can modify this! }} // External code can break encapsulation:order.getItems().clear(); // Deletes all items!order.getItems().add(null); // Adds invalid data! // ✅ GOOD: Return unmodifiable viewclass Order { private List<Item> items = new ArrayList<>(); public List<Item> getItems() { return Collections.unmodifiableList(items); } // Modifications go through domain methods public void addItem(Item item) { Objects.requireNonNull(item, "Item cannot be null"); items.add(item); }} // ❌ BAD: Getter with side effectsclass ReportGenerator { private int callCount = 0; public Report getReport() { callCount++; // Side effect in getter! return generateReport(); }} // ✅ GOOD: Separate concernsclass ReportGenerator { private int generationCount = 0; public Report generateReport() { // Named as action generationCount++; return doGenerateReport(); } public int getGenerationCount() { // Pure getter return generationCount; }}We've established a comprehensive understanding of why accessors exist and what purposes they serve. Here are the key takeaways:
What's Next:
Now that we understand why accessors exist, we'll explore when to use them—and when not to. The next page dives deep into when to use getters, examining legitimate use cases versus over-exposure of internal state.
You now understand the fundamental purpose of accessors in object-oriented design. They are not merely syntactic conveniences but essential tools for maintaining encapsulation, enforcing invariants, and enabling implementation evolution. Next, we'll explore when getters are appropriate and when they indicate a design problem.