Loading content...
Throughout this module, we've explored encapsulation through examples, examined common mistakes, and learned refactoring techniques. This final page synthesizes everything into a practical checklist—a systematic review process you can apply to any class design.
Use this checklist:
Each checklist item includes the principle, questions to ask, code symptoms to look for, and examples of proper implementation. Work through each section methodically; don't skip items even if they seem obvious for your particular case.
Principle: All fields should be private by default. Public fields violate encapsulation fundamentally.
public static final for immutable constants only| Pattern | Acceptable | Warning Signs |
|---|---|---|
private String name; | ✅ Always correct | — |
public String name; | ❌ Never acceptable | Direct field modification possible |
protected String name; | ⚠️ Rarely needed | Is subclass coupling intentional? |
String name; | ❌ Package-private | Allows access from same package |
public static final int MAX = 100; | ✅ For constants | Ensure truly immutable |
12345678910111213
public class WellEncapsulated { // ✅ Constants: public static final is acceptable public static final int MAX_NAME_LENGTH = 100; public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); // ✅ All instance fields are private private final String id; private String name; private Instant createdAt; // ⚠️ Protected only when inheritance contract requires it protected Logger logger; // Subclasses can use parent's logger}Principle: Accessors should be intentional, not mechanical. Each getter/setter should have a clear purpose.
12345678910111213141516171819202122232425262728293031323334
public class Customer { private final String id; private String name; private Email email; private Address billingAddress; private List<Order> orderHistory; // ✅ ID getter: legitimate read-only access public String getId() { return id; } // ✅ Name getter: needed for display public String getName() { return name; } // ❌ NO getName setter - use updateProfile() instead // ✅ Domain operation instead of setter public void updateProfile(String name, Email email) { this.name = validateName(name); this.email = Objects.requireNonNull(email); } // ✅ Defensive copy prevents external modification public List<Order> getOrderHistory() { return List.copyOf(orderHistory); } // ✅ No billing address setter - separate operation with validation public void updateBillingAddress(Address newAddress, AddressValidator validator) { if (!validator.isValid(newAddress)) { throw new InvalidAddressException("Address validation failed"); } this.billingAddress = newAddress; }}Principle: Prefer immutability when possible. Identity fields must be immutable.
| Field Type | Should Be Immutable? | Reason |
|---|---|---|
| Entity ID | ✅ Always final | Identity should never change |
| Creation timestamp | ✅ Always final | Historical fact cannot change |
| Entity type/kind | ✅ Usually final | Type rarely changes after creation |
| Name/description | ⚠️ Often mutable | Legitimate update scenarios exist |
| Status/state | ⚠️ Controlled mutability | Changes through lifecycle methods |
| Calculated values | ✅ Derive, don't store | Or cache with invalidation |
1234567891011121314151617181920212223242526272829303132333435
public class Order { // ALWAYS IMMUTABLE - Identity and history private final String orderId; private final String customerId; private final Instant createdAt; private final List<OrderLine> originalLines; // Snapshot at creation // CONTROLLED MUTABILITY - Through methods only private OrderStatus status; // Changed via lifecycle methods private Instant lastModified; // Updated on any change // DERIVED/CALCULATED - Not stored public Money getTotal() { return originalLines.stream() .map(OrderLine::getLineTotal) .reduce(Money.ZERO, Money::add); } // STATUS CHANGES - Validated transitions public void confirm() { if (status != OrderStatus.PENDING) { throw new InvalidStateTransitionException("Can only confirm pending orders"); } this.status = OrderStatus.CONFIRMED; this.lastModified = Instant.now(); } public void ship(ShippingInfo shipping) { if (status != OrderStatus.CONFIRMED) { throw new InvalidStateTransitionException("Can only ship confirmed orders"); } this.status = OrderStatus.SHIPPED; this.lastModified = Instant.now(); }}Principle: Internal collections must never be exposed for direct modification.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
public class Course { private final String courseId; private final Set<Student> enrolledStudents; private final List<Assignment> assignments; public Course(String courseId, Collection<Student> initialStudents) { this.courseId = courseId; // ✅ Defensive copy on INPUT this.enrolledStudents = new HashSet<>(initialStudents); this.assignments = new ArrayList<>(); } // ✅ Return unmodifiable view - lightweight, reflects current state public Set<Student> getEnrolledStudents() { return Collections.unmodifiableSet(enrolledStudents); } // ✅ Return defensive copy - isolated snapshot public List<Assignment> getAssignmentsCopy() { return new ArrayList<>(assignments); } // ✅ Stream for functional processing public Stream<Assignment> assignments() { return assignments.stream(); } // ✅ Domain operations for modification public void enrollStudent(Student student) { Objects.requireNonNull(student, "Student cannot be null"); if (enrolledStudents.size() >= MAX_ENROLLMENT) { throw new CourseFullException("Course has reached maximum enrollment"); } if (student.hasConflict(this.schedule)) { throw new ScheduleConflictException("Student has schedule conflict"); } enrolledStudents.add(student); } public boolean dropStudent(String studentId) { return enrolledStudents.removeIf(s -> s.getId().equals(studentId)); } public void addAssignment(Assignment assignment) { Objects.requireNonNull(assignment); if (assignments.stream().anyMatch(a -> a.getName().equals(assignment.getName()))) { throw new DuplicateAssignmentException("Assignment name already exists"); } assignments.add(assignment); } // ✅ Query methods instead of exposing collection public int getEnrollmentCount() { return enrolledStudents.size(); } public boolean isEnrolled(Student student) { return enrolledStudents.contains(student); } public Optional<Assignment> findAssignment(String name) { return assignments.stream() .filter(a -> a.getName().equals(name)) .findFirst(); }}Principle: Objects should always be in a valid state. Invalid state combinations should be impossible.
| Invariant Type | Example | Protection Method |
|---|---|---|
| Non-null fields | email must not be null | Objects.requireNonNull in constructor/setter |
| Format constraints | phone must match pattern | Regex validation on assignment |
| Range constraints | age must be 0-150 | Bounds checking in setter/constructor |
| Cross-field | start before end | Validate both together, atomic update |
| State transitions | PENDING → CONFIRMED only | Check current state before transition |
| Uniqueness | no duplicate items | Use Set or check before add |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
public final class Reservation { private final String reservationId; private final String guestId; private final LocalDate checkIn; private final LocalDate checkOut; private ReservationStatus status; private int guestCount; // ✅ Private constructor - all validation done private Reservation(Builder builder) { // Non-null checks this.reservationId = Objects.requireNonNull(builder.reservationId); this.guestId = Objects.requireNonNull(builder.guestId); this.checkIn = Objects.requireNonNull(builder.checkIn); this.checkOut = Objects.requireNonNull(builder.checkOut); // Cross-field invariant if (!checkOut.isAfter(checkIn)) { throw new IllegalArgumentException("Check-out must be after check-in"); } // Range constraint if (builder.guestCount < 1 || builder.guestCount > 10) { throw new IllegalArgumentException("Guest count must be 1-10"); } this.guestCount = builder.guestCount; // Business rule long nights = ChronoUnit.DAYS.between(checkIn, checkOut); if (nights > 30) { throw new IllegalArgumentException("Maximum stay is 30 nights"); } this.status = ReservationStatus.PENDING; } // ✅ State transition with validation public void confirm(PaymentInfo payment) { if (status != ReservationStatus.PENDING) { throw new InvalidStateException("Can only confirm pending reservations"); } if (!payment.isValid()) { throw new PaymentException("Invalid payment information"); } this.status = ReservationStatus.CONFIRMED; } public void cancel(String reason) { if (status == ReservationStatus.CHECKED_OUT) { throw new InvalidStateException("Cannot cancel completed stay"); } if (status == ReservationStatus.CANCELLED) { return; // Idempotent } this.status = ReservationStatus.CANCELLED; } // ✅ Modification with invariant maintenance public void modifyDates(LocalDate newCheckIn, LocalDate newCheckOut) { // Can only modify pending reservations if (status != ReservationStatus.PENDING) { throw new InvalidStateException("Can only modify pending reservations"); } // Validate new dates (same rules as construction) if (!newCheckOut.isAfter(newCheckIn)) { throw new IllegalArgumentException("Check-out must be after check-in"); } long nights = ChronoUnit.DAYS.between(newCheckIn, newCheckOut); if (nights > 30) { throw new IllegalArgumentException("Maximum stay is 30 nights"); } // Atomic update - both or neither // (Since these are final, we'd return a new Reservation in truly immutable design) } // ✅ Builder for complex construction public static class Builder { private String reservationId; private String guestId; private LocalDate checkIn; private LocalDate checkOut; private int guestCount = 1; public Builder reservationId(String id) { this.reservationId = id; return this; } public Builder guestId(String id) { this.guestId = id; return this; } public Builder checkIn(LocalDate date) { this.checkIn = date; return this; } public Builder checkOut(LocalDate date) { this.checkOut = date; return this; } public Builder guestCount(int count) { this.guestCount = count; return this; } public Reservation build() { return new Reservation(this); } }}Principle: Implementation details should be hidden. Only stable abstractions should be exposed.
123456789101112131415161718192021222324252627282930313233343536373839404142
public class UserService { // ✅ Implementation detail hidden - clients don't know it's Redis private final UserCache cache; // Interface, not RedisCache private final UserRepository repository; // Interface, not PostgresRepository public Optional<User> findById(String userId) { try { // Check cache first (implementation detail) Optional<User> cached = cache.get(userId); if (cached.isPresent()) { return cached; } // Fall back to database (implementation detail) Optional<User> fromDb = repository.findById(userId); fromDb.ifPresent(user -> cache.put(userId, user)); return fromDb; } catch (CacheConnectionException e) { // ✅ Don't leak cache implementation details // Degrade gracefully, log internally logger.warn("Cache unavailable, falling back to database", e); return repository.findById(userId); } catch (DatabaseException e) { // ✅ Wrap implementation exception in domain exception throw new UserNotFoundException("User not found: " + userId, e); } } // ✅ Private helper - clients don't see this complexity private User enrichWithPermissions(User user) { Set<Permission> permissions = permissionService.getFor(user.getId()); return user.withPermissions(permissions); } // ✅ Return type is interface, not implementation public List<User> findByDepartment(String department) { // Could return ArrayList, LinkedList, or unmodifiable - clients don't care return repository.findByDepartment(department); }}Here's a condensed checklist for quick reviews. Use this as a fast scan for obvious violations, then dive deeper with the detailed sections above when issues are found.
| Category | Quick Check |
|---|---|
| Fields | All fields private? Identity fields final? |
| Getters | Only necessary ones? No mutable object leaks? |
| Setters | Minimal? Validated? Domain operations preferred? |
| Collections | Defensive copies? Modification through methods? |
| Construction | Always valid? No partial initialization? |
| Invariants | Cross-field consistency? State transitions validated? |
| Dependencies | Interfaces over implementations? Details hidden? |
During code reviews, you can use shorthand references like 'ENC-1' (Field Access), 'ENC-2' (Accessors), etc. to quickly flag encapsulation issues. This makes reviews faster and creates a shared vocabulary for the team.
This checklist completes our exploration of encapsulation in practice. Let's recap the entire module:
You've completed the Encapsulation in Practice module. You now have practical tools for writing well-encapsulated code, identifying encapsulation violations, refactoring problematic designs, and reviewing code for encapsulation quality. Apply these principles consistently, and you'll build systems that are more maintainable, secure, and robust.
Next steps in the curriculum:
With encapsulation mastered, the next chapter explores Inheritance — how to model IS-A relationships and reuse code through class hierarchies, while avoiding the common pitfalls that make inheritance dangerous when misused.