Loading learning content...
In the previous page, we explored when getters are appropriate and when they leak implementation details. Now we turn to the more controversial topic: setters.
Setters are far more dangerous than getters. While a getter merely exposes information, a setter allows the outside world to mutate your object's internal state. This capability, if granted carelessly, undermines everything encapsulation is meant to protect.
The truth is: most setters shouldn't exist. They represent a failure to think about the object's behavior, a surrender of control to external code, and a recipe for inconsistent states and scattered business logic.
This page will teach you to recognize when setters are problematic, understand why they undermine encapsulation, and learn the alternatives that lead to stronger, more robust object designs.
If you're used to automatically generating getters and setters for every field, this page will challenge that habit. The goal is not to eliminate all setters, but to treat each one as a significant decision that requires justification.
To understand why setters are often harmful, consider what they really represent: unrestricted mutation of object state from anywhere in the codebase.
This creates several fundamental problems:
setStartDate() and setEndDate() individually cannot enforce that start < end12345678910111213141516171819202122
// Invariant: startDate must be before endDateclass DateRange { private LocalDate startDate; private LocalDate endDate; // These setters cannot enforce the invariant! public void setStartDate(LocalDate start) { this.startDate = start; // What if start > endDate? } public void setEndDate(LocalDate end) { this.endDate = end; // What if end < startDate? }} // Problem: Object can be in invalid stateDateRange range = new DateRange();range.setEndDate(LocalDate.of(2024, 1, 1));range.setStartDate(LocalDate.of(2024, 12, 31)); // start > end! // Even with validation, the order of setter calls mattersrange.setStartDate(LocalDate.of(2025, 1, 1)); // Invalid if end is 2024!123456789101112131415161718192021222324252627
// Setters push logic to callersclass Order { private OrderStatus status; private LocalDateTime shippedAt; private String trackingNumber; public void setStatus(OrderStatus status) { this.status = status; } public void setShippedAt(LocalDateTime time) { this.shippedAt = time; } public void setTrackingNumber(String num) { this.trackingNumber = num; }} // Every place that ships an order must know the logic:// Location 1:order.setStatus(OrderStatus.SHIPPED);order.setShippedAt(LocalDateTime.now());order.setTrackingNumber(tracking);notificationService.sendShippedEmail(order); // Location 2: Same logic, hopefully consistent...order.setStatus(OrderStatus.SHIPPED);order.setShippedAt(LocalDateTime.now()); // What if someone forgets this?order.setTrackingNumber(tracking); // Location 3: Oops, forgot to send notification!order.setStatus(OrderStatus.SHIPPED);order.setTrackingNumber(tracking);// shippedAt is null! Notification not sent!Setters treat objects as dumb data holders. Real objects should be rich with behavior, controlling their own state transitions and maintaining their own invariants. Every setter is a place where you've surrendered that control.
Certain situations should trigger an immediate "no setter" response. These are patterns where setters cause the most damage:
123456789101112131415161718192021222324
// ❌ NEVER: Setter for identitypublic class User { private String id; public void setId(String id) { // Dangerous! this.id = id; }} // ✅ CORRECT: Immutable identitypublic class User { private final String id; // Set once, never changes public User(String id, String name) { this.id = Objects.requireNonNull(id, "ID cannot be null"); // ... } public String getId() { return id; } // No setId()!}123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ❌ BAD: Separate setters for interdependent fieldspublic class Circle { private double radius; private double area; // Should always be PI * radius^2 public void setRadius(double r) { this.radius = r; // Must remember to update area! } public void setArea(double a) { this.area = a; // Must update radius? What if they don't match? }} // ✅ CORRECT: Derive area, only expose meaningful mutationpublic class Circle { private double radius; public Circle(double radius) { setRadius(radius); // Use internal setter for validation } public double getRadius() { return radius; } // Computed property - no setter needed public double getArea() { return Math.PI * radius * radius; } // Meaningful domain method with validation public void scale(double factor) { if (factor <= 0) { throw new IllegalArgumentException("Scale must be positive"); } this.radius *= factor; } // If setRadius is truly needed private void setRadius(double r) { if (r < 0) throw new IllegalArgumentException("Radius cannot be negative"); this.radius = r; }}setStatus() method cannot express these constraints123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ❌ BAD: Status setter allows invalid transitionspublic class Order { private OrderStatus status = OrderStatus.PENDING; public void setStatus(OrderStatus status) { this.status = status; // Any transition allowed! }} order.setStatus(OrderStatus.SHIPPED);order.setStatus(OrderStatus.PENDING); // Going backwards? Allowed! // ✅ CORRECT: Domain methods enforce valid transitionspublic class Order { private OrderStatus status = OrderStatus.PENDING; public OrderStatus getStatus() { return status; } // Explicit transition methods with rules public void confirm() { if (status != OrderStatus.PENDING) { throw new InvalidTransitionException( "Can only confirm pending orders, current: " + status ); } this.status = OrderStatus.CONFIRMED; // Trigger side effects: notification, logging, etc. } public void ship(String trackingNumber) { if (status != OrderStatus.CONFIRMED) { throw new InvalidTransitionException( "Can only ship confirmed orders, current: " + status ); } this.status = OrderStatus.SHIPPED; this.trackingNumber = trackingNumber; this.shippedAt = LocalDateTime.now(); } public void deliver() { if (status != OrderStatus.SHIPPED) { throw new InvalidTransitionException( "Can only deliver shipped orders, current: " + status ); } this.status = OrderStatus.DELIVERED; this.deliveredAt = LocalDateTime.now(); } public void cancel() { if (status == OrderStatus.DELIVERED) { throw new InvalidTransitionException( "Cannot cancel delivered orders" ); } this.status = OrderStatus.CANCELLED; }}12345678910111213141516171819202122232425262728293031323334
// ❌ BAD: Setter can't validate against contextpublic class Appointment { private LocalDateTime time; public void setTime(LocalDateTime time) { // Can't check if doctor is available at this time! // Can't check if patient has conflicting appointments! this.time = time; }} // ✅ CORRECT: Domain method with contextpublic class Appointment { private LocalDateTime time; public void reschedule( LocalDateTime newTime, SchedulingService scheduler) { if (!scheduler.isDoctorAvailable(doctor, newTime)) { throw new SchedulingException("Doctor not available"); } if (!scheduler.isPatientFree(patient, newTime)) { throw new SchedulingException("Patient has conflict"); } LocalDateTime oldTime = this.time; this.time = newTime; // Notify affected parties scheduler.notifyReschedule(this, oldTime, newTime); }}When you're tempted to add a setter, consider these alternatives that maintain encapsulation and invariants:
1234567891011121314151617181920212223242526
// ✅ Rich constructor - object born completepublic class Address { private final String street; private final String city; private final String postalCode; private final String country; public Address(String street, String city, String postalCode, String country) { // Validate everything at construction this.street = Objects.requireNonNull(street); this.city = Objects.requireNonNull(city); this.postalCode = validatePostalCode(postalCode, country); this.country = Objects.requireNonNull(country); } // No setters - object is immutable after construction} // For complex construction, use a builderAddress address = Address.builder() .street("123 Main St") .city("Springfield") .postalCode("12345") .country("USA") .build(); // Validates everything at build timeemployee.promote(newTitle, newSalary) instead of two setters12345678910111213141516171819202122232425262728293031
// ✅ Domain methods express intent and maintain invariantspublic class BankAccount { private Money balance; private List<Transaction> history; // Instead of setBalance(), use domain operations public void deposit(Money amount) { if (amount.isNegative()) { throw new IllegalArgumentException("Cannot deposit negative"); } this.balance = this.balance.add(amount); this.history.add(Transaction.deposit(amount)); } public void withdraw(Money amount) { if (amount.isNegative()) { throw new IllegalArgumentException("Cannot withdraw negative"); } if (amount.isGreaterThan(balance)) { throw new InsufficientFundsException(); } this.balance = this.balance.subtract(amount); this.history.add(Transaction.withdrawal(amount)); } public void transfer(BankAccount target, Money amount) { this.withdraw(amount); // Validates and updates this account target.deposit(amount); // Updates target account // Both accounts maintain their invariants }}withX() methods return new objects12345678910111213141516171819202122232425262728293031323334353637383940
// ✅ Immutable value object with copy methodspublic final class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { this.amount = amount.setScale(2, RoundingMode.HALF_UP); this.currency = currency; } // No setters - instead, return new instances public Money withAmount(BigDecimal newAmount) { return new Money(newAmount, this.currency); } public Money withCurrency(Currency newCurrency) { return new Money(this.amount, newCurrency); } // Operations return new instances public Money add(Money other) { ensureSameCurrency(other); return new Money( this.amount.add(other.amount), this.currency ); } public Money multiply(BigDecimal factor) { return new Money( this.amount.multiply(factor), this.currency ); }} // UsageMoney price = new Money(BigDecimal.valueOf(100), USD);Money discounted = price.multiply(BigDecimal.valueOf(0.9));// 'price' is unchanged, 'discounted' is new object123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// ✅ Builder for complex constructionpublic class HttpRequest { private final String method; private final URI url; private final Map<String, String> headers; private final Duration timeout; private final byte[] body; private HttpRequest(Builder builder) { this.method = builder.method; this.url = builder.url; this.headers = Map.copyOf(builder.headers); this.timeout = builder.timeout; this.body = builder.body != null ? builder.body.clone() : null; } public static Builder builder() { return new Builder(); } public static class Builder { private String method = "GET"; private URI url; private Map<String, String> headers = new HashMap<>(); private Duration timeout = Duration.ofSeconds(30); private byte[] body; public Builder method(String method) { this.method = method; return this; } public Builder url(URI url) { this.url = url; return this; } public Builder header(String name, String value) { this.headers.put(name, value); return this; } public Builder timeout(Duration timeout) { this.timeout = timeout; return this; } public Builder body(byte[] body) { this.body = body; return this; } public HttpRequest build() { Objects.requireNonNull(url, "URL is required"); if (body != null && method.equals("GET")) { throw new IllegalStateException("GET cannot have body"); } return new HttpRequest(this); } }} // Usage - fluent, validated at build timeHttpRequest request = HttpRequest.builder() .method("POST") .url(URI.create("https://api.example.com/data")) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(10)) .body(jsonBytes) .build();Despite the warnings, some situations genuinely warrant setters. The key is that these are deliberate decisions, not defaults:
setDescription(String) for a product with no description constraints12345678910111213141516171819202122
public class UserProfile { private String displayName; private String bio; // Acceptable: Independent field, simple validation public void setDisplayName(String name) { if (name == null || name.length() > 50) { throw new IllegalArgumentException( "Display name must be 1-50 characters" ); } this.displayName = name.trim(); } // Acceptable: Independent field, optional value public void setBio(String bio) { if (bio != null && bio.length() > 500) { throw new IllegalArgumentException("Bio too long"); } this.bio = bio; }}12345678910111213141516171819202122232425262728293031
// Framework requirement - JPA entity@Entitypublic class Product { @Id private Long id; private String name; private BigDecimal price; // JPA requires no-arg constructor protected Product() {} // Preferred: Create through rich constructor public Product(String name, BigDecimal price) { this.name = Objects.requireNonNull(name); this.price = Objects.requireNonNull(price); } // Package-private or protected setters for JPA void setId(Long id) { this.id = id; } void setName(String name) { this.name = name; } void setPrice(BigDecimal price) { this.price = price; } // Public API uses domain methods public void updatePrice(BigDecimal newPrice) { if (newPrice.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Price must be positive"); } this.price = newPrice; // Log, notify, etc. }}For every setter you create, you should be able to answer: 'Why can't this be set at construction time?' and 'Why can't this be a domain method?' If you can't answer satisfactorily, the setter probably shouldn't exist.
If you have existing code with setters, here's how to migrate toward better designs:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// BEFORE: Setters everywherepublic class Employee { private String title; private BigDecimal salary; private String department; public void setTitle(String title) { this.title = title; } public void setSalary(BigDecimal salary) { this.salary = salary; } public void setDepartment(String dept) { this.department = dept; }} // Callers must coordinateemployee.setTitle("Senior Engineer");employee.setSalary(new BigDecimal("120000"));// What if someone forgets to update salary when promoting? // AFTER: Domain methods encode business operationspublic class Employee { private String title; private BigDecimal salary; private String department; public void promote(String newTitle, BigDecimal newSalary) { Objects.requireNonNull(newTitle, "Title required for promotion"); if (newSalary.compareTo(this.salary) < 0) { throw new IllegalArgumentException( "Promotion salary must be higher than current" ); } this.title = newTitle; this.salary = newSalary; // Could: log, notify HR, trigger review cycle, etc. } public void transfer(String newDepartment) { Objects.requireNonNull(newDepartment); String oldDepartment = this.department; this.department = newDepartment; // Could: notify old/new managers, update seating, etc. } public void adjustSalary(BigDecimal newSalary, String reason) { Objects.requireNonNull(reason, "Salary adjustment requires reason"); this.salary = newSalary; // Log the adjustment with reason for compliance }}We've explored why setters are often harmful and when they should be avoided. Here are the key takeaways:
What's Next:
We've now covered when to use getters and when to avoid setters. The next page introduces the Tell, Don't Ask principle—a powerful design guideline that ties these concepts together and points toward more behavioral, less data-centric object designs.
You now understand why setters are often more problematic than getters and when to avoid them. The key insight is that objects should control their own state transitions through domain methods, not expose raw mutation through setters. Next, we'll explore the Tell, Don't Ask principle that formalizes this approach.