Chapter 3: Encapsulation and Safe Domain Rules
Chapter 2 introduced classes and objects for the e-commerce project. Chapter 3 makes those objects safer. The learner now protects object state, controls how values change, validates updates, and designs methods that keep store models trustworthy. This is where the project moves from 'objects exist' to 'objects enforce rules.'
Why Public Fields Break Trust in a Store Model
Once Product and CartItem objects exist, the next question is whether any part of the program can change them in any way it wants. If the answer is yes, the model is fragile. A public `stock` field can be set to a negative number. A public `quantity` field can jump from 2 to -400. The object still exists, but it no longer represents a meaningful business state.
Encapsulation solves that by limiting how object state is reached and changed. Instead of exposing every field directly, the object keeps control over its own data. Outside code must go through methods that can protect the rules.
This is why encapsulation matters so early in an application project. It is not a decorative OOP term. It is how you stop invalid business state from spreading through the system.
- Public mutable fields let any caller break object state.
- Encapsulation protects the truth of the model.
- A safe object controls how its data is accessed and changed.
- This is a business correctness issue, not only a style issue.
public class Product {
public String name;
public double unitPrice;
public int stock;
}
// Any caller can now set stock = -100, even if that makes no business sense.
Access Modifiers and Designing a Safe Visibility Boundary
Access modifiers are one of Java’s first tools for controlling who can see and change state. `private` keeps details inside the class. `public` exposes a method or type broadly. Package-private and `protected` create narrower visibility boundaries when the design needs them.
For beginner domain modeling, the most important habit is simple: keep fields private unless there is a strong reason not to. That single decision forces the model to become more intentional. If outside code needs information, it must ask for it through a method. If outside code needs to change something, the class can decide whether the change is valid.
Visibility is part of API design. Every field or method you expose becomes something other code may depend on. Once exposed, it becomes harder to change safely. That is why visibility should be treated as a design decision, not just a syntax choice.
- Private fields protect state from uncontrolled outside access.
- Public methods form the visible API of the class.
- Visibility boundaries are part of long-term maintainability.
- A smaller public surface is usually easier to evolve safely.
public class Product {
private String name;
private double unitPrice;
private int stock;
}
Controlled Mutation Instead of Unrestricted State Changes
Encapsulation does not mean nothing can ever change. It means changes should happen through intentional methods. A `Product` should not let callers write directly to `stock`. Instead, it can offer methods such as `restock(…)` or `reserve(…)` that make the business action explicit.
This is important because the method can check the rule before making the change. A quantity increase may be fine. A quantity decrease below zero is not. When the method name reflects the business meaning, the code also becomes easier to read.
Controlled mutation is one of the first moments where object-oriented Java feels stronger than procedural scripts. The object is no longer passive. It actively protects the meaning of its own data.
- State changes should be expressed as meaningful operations.
- A method like restock or reserve is clearer than raw assignment.
- Controlled mutation gives the class a chance to reject invalid updates.
- Behavior-rich objects make outside code simpler and safer.
public void restock(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Restock amount must be positive");
}
stock += amount;
}
public void reserve(int amount) {
if (amount <= 0 || amount > stock) {
throw new IllegalArgumentException("Invalid reservation amount");
}
stock -= amount;
}
Invariants and Validation: The Rules an Object Must Never Break
An invariant is a condition that should always remain true for a valid object. A product price should not be negative. A cart item quantity should be at least one. A customer email should not be blank. These rules define what it means for the object to be valid.
Validation is how the class protects those invariants. It can happen in constructors, update methods, and factory methods. The exact location depends on the design, but the principle stays the same: invalid state should be rejected as close to the source as possible.
Once learners understand invariants, encapsulation becomes much easier to justify. Private fields are not just hidden for style. They are hidden so the class can defend the rules that make the object trustworthy.
- Invariants define the conditions a valid object must always satisfy.
- Validation protects those rules when objects are created or changed.
- Rejecting bad input early prevents wider system corruption.
- Encapsulation exists partly to defend invariants.
public CartItem(Product product, int quantity) {
this.product = Objects.requireNonNull(product, "product");
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be at least 1");
}
this.quantity = quantity;
}
Getters, Setters, and Why Not Every Field Needs a Setter
A common beginner pattern is to generate getters and setters for every field automatically. That is easy, but it is often too permissive. If every field can be changed from anywhere, the class has almost no protection left.
A getter is often harmless when the information should be visible. A setter is more serious because it defines a write path into the object. Before adding one, ask whether the field should really be changed directly or whether a more meaningful method would be safer.
For example, `setStock(5)` says very little about why the value changed. `reserve(2)` or `restock(5)` says exactly what business action happened. That difference becomes powerful in larger systems because the code tells the story of the domain more clearly.
- Getters expose information; setters expose mutation paths.
- Not every field deserves a setter.
- Meaningful domain methods are often safer than generic setters.
- Generated boilerplate is not automatically good design.
public int stock() {
return stock;
}
public void changeQuantity(int newQuantity) {
if (newQuantity <= 0) {
throw new IllegalArgumentException("Quantity must stay positive");
}
quantity = newQuantity;
}
Immutability Basics and When a Value Should Never Change
Not all objects need mutation. Some values are safer when they never change after creation. A customer id, a product sku, or an address snapshot may be easier to trust if the object is immutable. Java supports that style well with `final` fields and constructor-based setup.
Immutability reduces whole categories of bugs because callers do not need to wonder who changed the object later. It also makes reasoning easier when data is shared across methods.
This chapter introduces immutability as a design option, not a universal rule. Some objects in the store domain, like stock levels, naturally change. Others are better treated as fixed values. Recognizing that difference is part of growing into stronger object design.
- Immutable objects cannot be changed after creation.
- Final fields help make value-like objects safer and easier to trust.
- Some domain concepts naturally change, while others should stay fixed.
- Immutability is a design choice that reduces mutation-related bugs.
public final class CustomerProfile {
private final String email;
private final boolean premium;
public CustomerProfile(String email, boolean premium) {
this.email = email;
this.premium = premium;
}
}
Defensive Copying and Protecting Internal Collections
Encapsulation becomes more subtle when an object holds a collection. If a Cart exposes its internal item list directly, outside code may change that list without the Cart approving the change. The class looks encapsulated, but its internal state is still leaking.
Defensive copying is one way to protect against that. The class can return an unmodifiable view or a copied list instead of the original mutable collection. That keeps the object’s internal structure under its own control.
This idea matters because many Java bugs are not about one field. They are about hidden shared mutable data. Defensive copying teaches learners that encapsulation is about protecting structures, not just protecting primitives and strings.
- Returning a raw mutable collection can break encapsulation.
- Defensive copies or unmodifiable views protect internal state.
- Collection leaks are a common source of accidental mutation bugs.
- Encapsulation must cover internal structures as well as simple fields.
public List<CartItem> items() {
return List.copyOf(items);
}
Mini Project Refactor: Tighten Product, Customer, and CartItem Rules
The Chapter 3 milestone is to tighten the Chapter 2 model so it behaves like a trustworthy domain instead of a set of public data bags. Fields become private. Constructors validate initial state. Meaningful methods replace direct mutation. Collections stop leaking. The code is still small, but it now carries stronger guarantees.
This step is important because later chapters depend on it. Collections work better when the objects inside them are trustworthy. Persistence becomes simpler when object rules are explicit. Testing becomes easier when behavior is concentrated in well-defined methods.
That is the value of encapsulation in a project-based curriculum: it does not exist as theory alone. It improves the exact code the learner already built.
- The Chapter 3 milestone is safer object behavior, not new infrastructure.
- A tighter model makes later chapters easier to design and test.
- Encapsulation changes the quality of the project immediately.
- Objects should now defend their own rules instead of trusting all callers.
public class Product {
private final String name;
private double unitPrice;
private int stock;
public Product(String name, double unitPrice, int stock) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("name");
if (unitPrice < 0) throw new IllegalArgumentException("price");
if (stock < 0) throw new IllegalArgumentException("stock");
this.name = name;
this.unitPrice = unitPrice;
this.stock = stock;
}
}
Chapter takeaway
Encapsulation is how Java objects protect the truth of the model. If a Product, CartItem, or Customer can only change through safe methods, the code becomes easier to reason about, easier to test, and much harder to corrupt by accident.