Java Tutorial
🔍

Java Encapsulation

Java Encapsulation

Encapsulation is the practice of bundling data and the methods that operate on that data into a single class, and restricting direct access to the data from outside. The fields are hidden. The behaviour is exposed. External code interacts with the object only through the methods the class provides — it never reaches directly into the object's state.

This is not just a rule about using private. Encapsulation is a design principle about control. The class controls what state it holds, what changes it allows, and what rules it enforces. Without encapsulation, any code anywhere can corrupt any object's state — and the class has no say in it.

What Is Encapsulation in Java?

Encapsulation is one of the four pillars of Object-Oriented Programming. It combines two things:

  • Data hiding — declaring fields private so external code cannot access them directly
  • Controlled access — providing public methods (getters and setters) that enforce rules about how data is read and modified

A well-encapsulated class is one where:

  1. All fields are private
  2. Read access is provided through getter methods when needed
  3. Write access is provided through setter methods with validation when needed
  4. The class itself enforces all business rules about its data

The result is that the class is the single authority on its own state. No other class can bypass the rules.

Why Encapsulation Matters

Consider what happens without it:

Java
1// Without encapsulation — any code can corrupt the object 2public class BankAccount { 3 public double balance; // anyone can set this to anything 4 public String accountId; 5} 6 7// Caller can do this — no validation, no control 8BankAccount account = new BankAccount(); 9account.balance = -99999999.0; // negative balance — invalid but unchecked 10account.accountId = null; // corrupted state

Now consider with encapsulation:

Java
1// With encapsulation — only controlled changes are possible 2public class BankAccount { 3 private double balance; 4 private final String accountId; 5 6 public BankAccount(String accountId, double initialDeposit) { 7 this.accountId = accountId; 8 this.balance = Math.max(0.0, initialDeposit); 9 } 10 11 public double getBalance() { return balance; } 12 13 public boolean deposit(double amount) { 14 if (amount <= 0) return false; 15 balance += amount; 16 return true; 17 } 18 19 public boolean withdraw(double amount) { 20 if (amount <= 0 || amount > balance) return false; 21 balance -= amount; 22 return true; 23 } 24}

In the second version, no external code can set balance to a negative value. The business rules live inside the class where they belong — not scattered in every caller that modifies the object.

Getters and Setters

Getters provide read access to private fields. Setters provide write access — and crucially, they can validate input before allowing the change.

Java
1// File: UserProfile.java 2 3public class UserProfile { 4 5 private String username; 6 private String email; 7 private int age; 8 private boolean isPremiumMember; 9 10 public UserProfile(String username, String email, int age) { 11 setUsername(username); // uses setter for validation even in constructor 12 setEmail(email); 13 setAge(age); 14 this.isPremiumMember = false; 15 } 16 17 // Getter — read access only 18 public String getUsername() { return username; } 19 public String getEmail() { return email; } 20 public int getAge() { return age; } 21 public boolean isPremiumMember() { return isPremiumMember; } 22 23 // Setter with validation 24 public void setUsername(String username) { 25 if (username == null || username.trim().length() < 3) { 26 throw new IllegalArgumentException( 27 "Username must be at least 3 characters."); 28 } 29 this.username = username.trim().toLowerCase(); 30 } 31 32 public void setEmail(String email) { 33 if (email == null || !email.contains("@")) { 34 throw new IllegalArgumentException("Invalid email address: " + email); 35 } 36 this.email = email.trim().toLowerCase(); 37 } 38 39 public void setAge(int age) { 40 if (age < 13 || age > 120) { 41 throw new IllegalArgumentException( 42 "Age must be between 13 and 120. Got: " + age); 43 } 44 this.age = age; 45 } 46 47 public void upgradeToPremium() { 48 this.isPremiumMember = true; 49 } 50 51 public String getSummary() { 52 return username + " | " + email + " | Age: " + age 53 + " | Premium: " + isPremiumMember; 54 } 55}
Java
1// File: GetterSetterDemo.java 2 3public class GetterSetterDemo { 4 5 public static void main(String[] args) { 6 7 UserProfile user = new UserProfile(" Rahul_Dev ", "Rahul@Example.COM", 25); 8 System.out.println(user.getSummary()); 9 10 // Valid update 11 user.setEmail("rahul.dev@company.com"); 12 user.upgradeToPremium(); 13 System.out.println(user.getSummary()); 14 15 // Invalid update — throws exception with clear message 16 try { 17 user.setAge(-5); 18 } catch (IllegalArgumentException ex) { 19 System.out.println("Rejected: " + ex.getMessage()); 20 } 21 22 // Invalid constructor — caught at creation time 23 try { 24 UserProfile invalid = new UserProfile("AB", "notanemail", 25); 25 } catch (IllegalArgumentException ex) { 26 System.out.println("Rejected: " + ex.getMessage()); 27 } 28 } 29}
Output:
rahul_dev | rahul@example.com | Age: 25 | Premium: false
rahul_dev | rahul.dev@company.com | Age: 25 | Premium: true
Rejected: Age must be between 13 and 120. Got: -5
Rejected: Username must be at least 3 characters.

The username is automatically trimmed and lowercased in the setter. The age validation rejects impossible values. These rules are defined once, inside UserProfile, and they apply to every modification — whether the caller is the constructor, a direct setter call, or any future code path. During code reviews, input validation scattered in callers rather than centralised in setters is consistently flagged.

Encapsulation and Immutability

The strongest form of encapsulation is an immutable class — one whose state cannot change after construction. Every field is final, there are no setters, and any mutable objects stored as fields are defensively copied.

Immutable objects are inherently thread-safe. Multiple threads can read them simultaneously without synchronisation. They eliminate an entire class of bugs where shared state is modified unexpectedly.

Java
1// File: Money.java 2 3import java.util.Objects; 4 5public final class Money { 6 7 // final — cannot be reassigned after construction 8 private final double amount; 9 private final String currency; 10 11 public Money(double amount, String currency) { 12 if (amount < 0) { 13 throw new IllegalArgumentException("Amount cannot be negative: " + amount); 14 } 15 if (currency == null || currency.isBlank()) { 16 throw new IllegalArgumentException("Currency is required."); 17 } 18 this.amount = amount; 19 this.currency = currency.toUpperCase().trim(); 20 } 21 22 public double getAmount() { return amount; } 23 public String getCurrency() { return currency; } 24 25 // Operations return new Money objects — the original is never modified 26 public Money add(Money other) { 27 if (!this.currency.equals(other.currency)) { 28 throw new IllegalArgumentException( 29 "Cannot add different currencies: " + this.currency 30 + " and " + other.currency); 31 } 32 return new Money(this.amount + other.amount, this.currency); 33 } 34 35 public Money subtract(Money other) { 36 if (!this.currency.equals(other.currency)) { 37 throw new IllegalArgumentException("Cannot subtract different currencies."); 38 } 39 if (other.amount > this.amount) { 40 throw new IllegalArgumentException("Insufficient funds."); 41 } 42 return new Money(this.amount - other.amount, this.currency); 43 } 44 45 public Money applyDiscount(double discountPercent) { 46 if (discountPercent < 0 || discountPercent > 100) { 47 throw new IllegalArgumentException("Discount must be 0-100%."); 48 } 49 return new Money(this.amount * (1 - discountPercent / 100.0), this.currency); 50 } 51 52 @Override 53 public String toString() { 54 return currency + " " + String.format("%.2f", amount); 55 } 56 57 @Override 58 public boolean equals(Object obj) { 59 if (this == obj) return true; 60 if (!(obj instanceof Money other)) return false; 61 return Double.compare(this.amount, other.amount) == 0 62 && this.currency.equals(other.currency); 63 } 64 65 @Override 66 public int hashCode() { 67 return Objects.hash(amount, currency); 68 } 69}
Java
1// File: ImmutableDemo.java 2 3public class ImmutableDemo { 4 5 public static void main(String[] args) { 6 7 Money price = new Money(1499.0, "INR"); 8 Money discount = price.applyDiscount(10); 9 Money delivery = new Money(49.0, "INR"); 10 Money total = discount.add(delivery); 11 12 System.out.println("Original price : " + price); 13 System.out.println("After 10% disc : " + discount); 14 System.out.println("Delivery fee : " + delivery); 15 System.out.println("Total payable : " + total); 16 17 // price is still the original — immutability guarantee 18 System.out.println("\nOriginal unchanged: " + price); 19 } 20}
Output:
Original price : INR 1499.00
After 10% disc : INR 1349.10
Delivery fee   : INR 49.00
Total payable  : INR 1398.10

Original unchanged: INR 1499.00

applyDiscount() and add() return new Money objects — the original price is never modified. You can pass a Money object to any method, any thread, anywhere without fear that it will be changed. This is why Java's String, Integer, LocalDate, and BigDecimal are all immutable — the safety guarantees justify the design cost.

Defensive Copying

When an encapsulated class holds a reference to a mutable object — a List, Date, or a custom class with setters — returning that reference directly allows callers to modify the internal state from outside, bypassing encapsulation.

Defensive copying creates a copy of the mutable object on the way in (constructor/setter) and on the way out (getter), so the caller never holds a direct reference to the internal object.

Java
1// File: CourseEnrollment.java 2 3import java.util.ArrayList; 4import java.util.Collections; 5import java.util.List; 6 7public class CourseEnrollment { 8 9 private final String studentId; 10 private final String courseCode; 11 // Internal list — must not be exposed directly 12 private final List<String> completedModules; 13 14 public CourseEnrollment(String studentId, String courseCode, 15 List<String> initialModules) { 16 this.studentId = studentId; 17 this.courseCode = courseCode; 18 // Defensive copy on the way IN — caller's list changes don't affect us 19 this.completedModules = new ArrayList<>(initialModules); 20 } 21 22 public String getStudentId() { return studentId; } 23 public String getCourseCode() { return courseCode; } 24 25 // Defensive copy on the way OUT — caller cannot modify our internal list 26 public List<String> getCompletedModules() { 27 return Collections.unmodifiableList(completedModules); 28 } 29 30 public void completeModule(String moduleName) { 31 if (moduleName == null || moduleName.isBlank()) { 32 throw new IllegalArgumentException("Module name cannot be blank."); 33 } 34 if (completedModules.contains(moduleName)) { 35 throw new IllegalStateException("Module already completed: " + moduleName); 36 } 37 completedModules.add(moduleName); 38 } 39 40 public int getCompletedCount() { return completedModules.size(); } 41 42 public String getSummary() { 43 return studentId + " | " + courseCode 44 + " | Completed: " + completedModules.size() + " modules"; 45 } 46}
Java
1// File: DefensiveCopyDemo.java 2 3import java.util.ArrayList; 4import java.util.List; 5 6public class DefensiveCopyDemo { 7 8 public static void main(String[] args) { 9 10 List<String> startingModules = new ArrayList<>(); 11 startingModules.add("Module 1: Basics"); 12 startingModules.add("Module 2: OOP"); 13 14 CourseEnrollment enrollment = new CourseEnrollment( 15 "STU-001", "JAVA-101", startingModules 16 ); 17 18 System.out.println(enrollment.getSummary()); 19 20 // Modifying the original list DOES NOT affect the enrollment 21 startingModules.add("Hacked Module"); // caller tries to inject a module 22 System.out.println("After caller modifies original list:"); 23 System.out.println(enrollment.getSummary()); // count stays at 2 24 25 // Caller cannot modify the returned list either 26 List<String> returnedModules = enrollment.getCompletedModules(); 27 try { 28 returnedModules.add("Another Hack"); // throws UnsupportedOperationException 29 } catch (UnsupportedOperationException ex) { 30 System.out.println("Cannot modify returned list: " + ex.getClass().getSimpleName()); 31 } 32 33 // Only through the proper method is state change allowed 34 enrollment.completeModule("Module 3: Collections"); 35 System.out.println("\n" + enrollment.getSummary()); 36 System.out.println("Modules: " + enrollment.getCompletedModules()); 37 } 38}
Output:
STU-001 | JAVA-101 | Completed: 2 modules
After caller modifies original list:
STU-001 | JAVA-101 | Completed: 2 modules
Cannot modify returned list: UnsupportedOperationException

STU-001 | JAVA-101 | Completed: 3 modules
Modules: [Module 1: Basics, Module 2: OOP, Module 3: Collections]

Without defensive copying, startingModules.add("Hacked Module") would silently add a module to the enrollment. The caller's reference and the internal list would be the same object. This is a subtle but real encapsulation breach — the internal state can be changed from outside without going through any of the class's validation logic.

Encapsulation vs Abstraction

These two pillars are closely related and frequently confused in interviews. Understanding the precise difference is what interviewers check.

AspectEncapsulationAbstraction
Core ideaHiding data and implementation detailsHiding complexity by showing only essential behaviour
FocusHow state is protected inside a classWhat interface is presented to the outside
Implemented usingprivate fields, getters, settersAbstract classes, interfaces
Answers the question"How is the data protected?""What can the object do?"
LevelClass-level data protectionSystem-level design principle
Exampleprivate double balance with deposit() and withdraw()PaymentGateway interface hiding Razorpay vs PayU implementation
Runtime behaviourEnforced by access modifiers at compile timeEnforced through polymorphism at runtime

The practical distinction: Encapsulation is about protecting the contents of a class. Abstraction is about simplifying the interface of a system. A class can be well-encapsulated (private fields, controlled access) while still exposing many methods — encapsulation does not hide what the object does, only what it contains. Abstraction hides what the object does and only shows that it can do something.

Getter vs Setter — Design Considerations

AspectGetterSetter
PurposeRead a field valueWrite a field value with validation
ReturnsThe field value (or a copy for mutable types)void (or the object for chaining)
ValidationRarely — just returnsEssential — validates before setting
RequiredOnly when callers genuinely need to read itOnly when callers are allowed to change it
Not every field needs oneYes — provide only what is necessaryYes — fewer setters = stronger encapsulation
Immutable classGetters only — no settersNo setters — values set only in constructor

Not every private field needs both a getter and a setter. A field that is set in the constructor and never changes needs a getter but no setter. A field that is purely internal bookkeeping (a timestamp, a sequence counter) may not need either. Every getter and setter you add is an API commitment — add only what callers genuinely need.

Mutable vs Immutable Classes

AspectMutable ClassImmutable Class
FieldsCan change after constructionCannot change — final
SettersPresentNone
Thread safetyRequires synchronisationInherently thread-safe
Defensive copy on returnRequired for mutable fieldsNot needed — fields cannot change
EqualityMay change over timeStable — safe to use in HashMap keys
ExampleUserProfile with settersMoney, String, LocalDate
OperationsModify state in placeReturn new objects
CachingRisky — cached value may go staleSafe — value never changes

Real-World Example — Delivery Slot Booking System

The Business Problem

You are building the delivery slot reservation system for a quick-commerce platform — similar to what Swiggy Instamart or Blinkit uses for its scheduled delivery feature. Each slot has a capacity limit. Once booked to capacity, it must reject further bookings. The slot's time window and capacity cannot change after creation. Booking and cancellation must go through controlled methods. Internal booking state must not be directly accessible or modifiable.

Implementation

Java
1// File: DeliverySlotConfig.java 2 3public final class DeliverySlotConfig { 4 5 public static final int DEFAULT_SLOT_CAPACITY = 50; 6 public static final int MIN_ADVANCE_BOOKING_HRS = 1; 7 8 private DeliverySlotConfig() {} 9}
Java
1// File: DeliverySlot.java 2 3import java.time.LocalDateTime; 4import java.time.format.DateTimeFormatter; 5import java.util.ArrayList; 6import java.util.Collections; 7import java.util.List; 8 9public class DeliverySlot { 10 11 // Immutable identity fields — set once, never changed 12 private final String slotId; 13 private final LocalDateTime slotStart; 14 private final LocalDateTime slotEnd; 15 private final int capacity; 16 17 // Mutable state — controlled through methods only 18 private int confirmedBookings; 19 private final List<String> bookedOrderIds; 20 21 public DeliverySlot(String slotId, LocalDateTime slotStart, 22 LocalDateTime slotEnd, int capacity) { 23 if (slotEnd.isBefore(slotStart)) { 24 throw new IllegalArgumentException("Slot end must be after start."); 25 } 26 if (capacity <= 0) { 27 throw new IllegalArgumentException("Capacity must be positive."); 28 } 29 this.slotId = slotId; 30 this.slotStart = slotStart; 31 this.slotEnd = slotEnd; 32 this.capacity = capacity; 33 this.confirmedBookings = 0; 34 this.bookedOrderIds = new ArrayList<>(); 35 } 36 37 // Getters for identity fields — always safe to expose 38 public String getSlotId() { return slotId; } 39 public LocalDateTime getSlotStart() { return slotStart; } 40 public LocalDateTime getSlotEnd() { return slotEnd; } 41 public int getCapacity() { return capacity; } 42 43 // Derived getters — computed from internal state 44 public int getConfirmedBookings() { return confirmedBookings; } 45 public int getAvailableSlots() { return capacity - confirmedBookings; } 46 public boolean isAvailable() { return confirmedBookings < capacity; } 47 48 // Defensive copy — caller cannot modify our internal list 49 public List<String> getBookedOrderIds() { 50 return Collections.unmodifiableList(bookedOrderIds); 51 } 52 53 // Controlled booking — validates before allowing state change 54 public boolean book(String orderId) { 55 if (orderId == null || orderId.isBlank()) { 56 throw new IllegalArgumentException("Order ID is required."); 57 } 58 if (!isAvailable()) { 59 return false; // slot full — booking rejected 60 } 61 if (bookedOrderIds.contains(orderId)) { 62 throw new IllegalStateException("Order already booked in this slot: " + orderId); 63 } 64 bookedOrderIds.add(orderId); 65 confirmedBookings++; 66 return true; 67 } 68 69 // Controlled cancellation — validates before allowing state change 70 public boolean cancel(String orderId) { 71 boolean removed = bookedOrderIds.remove(orderId); 72 if (removed) { 73 confirmedBookings--; 74 } 75 return removed; 76 } 77 78 public String getSlotSummary() { 79 DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm"); 80 return slotId + " | " + slotStart.format(fmt) 81 + "-" + slotEnd.format(fmt) 82 + " | Booked: " + confirmedBookings + "/" + capacity 83 + " | " + (isAvailable() ? "OPEN" : "FULL"); 84 } 85}
Java
1// File: SlotBookingDemo.java 2 3import java.time.LocalDateTime; 4 5public class SlotBookingDemo { 6 7 public static void main(String[] args) { 8 9 LocalDateTime base = LocalDateTime.now().withHour(10).withMinute(0); 10 11 DeliverySlot morningSlot = new DeliverySlot( 12 "SLOT-AM-01", 13 base, 14 base.plusHours(2), 15 3 // small capacity for demo 16 ); 17 18 System.out.println("=== Slot Booking System ===\n"); 19 System.out.println(morningSlot.getSlotSummary()); 20 21 // Valid bookings 22 System.out.println("\nBooking ORD-001: " + morningSlot.book("ORD-001")); 23 System.out.println("Booking ORD-002: " + morningSlot.book("ORD-002")); 24 System.out.println("Booking ORD-003: " + morningSlot.book("ORD-003")); 25 System.out.println(morningSlot.getSlotSummary()); 26 27 // Slot is full — booking rejected 28 System.out.println("\nBooking ORD-004: " + morningSlot.book("ORD-004")); 29 System.out.println(morningSlot.getSlotSummary()); 30 31 // Cancellation frees a slot 32 System.out.println("\nCancelling ORD-002: " + morningSlot.cancel("ORD-002")); 33 System.out.println(morningSlot.getSlotSummary()); 34 35 // Slot is available again — new booking accepted 36 System.out.println("\nBooking ORD-005: " + morningSlot.book("ORD-005")); 37 System.out.println(morningSlot.getSlotSummary()); 38 39 // Cannot modify the returned order list 40 try { 41 morningSlot.getBookedOrderIds().add("HACK-001"); 42 } catch (UnsupportedOperationException ex) { 43 System.out.println("\nExternal modification blocked: " 44 + ex.getClass().getSimpleName()); 45 } 46 47 System.out.println("Final bookings: " + morningSlot.getBookedOrderIds()); 48 } 49}
Output:
=== Slot Booking System ===

SLOT-AM-01 | 10:00-12:00 | Booked: 0/3 | OPEN
Booking ORD-001: true
Booking ORD-002: true
Booking ORD-003: true
SLOT-AM-01 | 10:00-12:00 | Booked: 3/3 | FULL

Booking ORD-004: false
SLOT-AM-01 | 10:00-12:00 | Booked: 3/3 | FULL

Cancelling ORD-002: true
SLOT-AM-01 | 10:00-12:00 | Booked: 2/3 | OPEN

Booking ORD-005: true
SLOT-AM-01 | 10:00-12:00 | Booked: 3/3 | FULL

External modification blocked: UnsupportedOperationException
Final bookings: [ORD-001, ORD-003, ORD-005]

confirmedBookings and bookedOrderIds are private — no external code can directly manipulate the booking count. capacity, slotStart, and slotEnd are final — once set, they never change. The book() and cancel() methods are the only doorways through which state changes, and both enforce their own rules before touching the internal data.

Best Practices

Make all fields private by default — widen access only when justified

The discipline is to start with private for every field and widen to protected or public only when there is a specific, justified reason. Every non-private field is an API commitment — once external code depends on it, changing it becomes a breaking change. Teams following clean code conventions treat any public field as a design smell requiring justification in review.

Validate in setters, not in callers

When validation logic lives in callers rather than in setters, the same check must be duplicated everywhere the field is set. When a business rule changes — say, the minimum username length goes from 3 to 5 characters — only the setter changes. If validation is in callers, every caller must be found and updated, and one will inevitably be missed.

Return defensive copies of mutable fields

If a getter returns a direct reference to a mutable internal field — a List, Date, or custom object with setters — the caller can modify the internal state from outside. Always return Collections.unmodifiableList(internalList) or new ArrayList<>(internalList) for lists, and new copies for mutable objects. Java's String is immune to this because it is immutable.

Prefer immutable classes when objects should not change after creation

If an object's state never needs to change — monetary values, addresses, configuration entries, identifiers — make it immutable. Declare all fields final, provide no setters, and use defensive copies in the constructor for any mutable input. Immutable objects are the simplest form of encapsulation and the safest for use in multi-threaded systems.

Common Mistakes

Mistake 1 — Returning a Direct Reference to a Mutable Field

Java
1import java.util.List; 2import java.util.ArrayList; 3 4public class OrderHistory { 5 6 private final List<String> orderIds = new ArrayList<>(); 7 8 public void addOrder(String orderId) { 9 orderIds.add(orderId); 10 } 11 12 // Wrong — returns direct reference, caller can modify internal state 13 public List<String> getOrderIds() { 14 return orderIds; // caller can call orderIds.clear() on this 15 } 16 17 // Correct — returns an unmodifiable view 18 public List<String> getOrderIdsSafe() { 19 return java.util.Collections.unmodifiableList(orderIds); 20 } 21}

A caller who receives the direct reference from getOrderIds() can call list.add(...), list.remove(...), or list.clear() — bypassing all control. The class has no idea its state has been changed.

Mistake 2 — Providing Setters for Every Field Without Thinking

Java
1public class PaymentTransaction { 2 3 private final String transactionId; 4 private double amount; 5 private String status; 6 private String gatewayResponse; 7 8 // Wrong — why would anyone need to change the transactionId after creation? 9 public void setTransactionId(String transactionId) { /* dangerous */ } 10 11 // Wrong — amount should not change after a transaction is created 12 public void setAmount(double amount) { this.amount = amount; /* no validation */ } 13 14 // Reasonable — status legitimately changes as the transaction progresses 15 public void updateStatus(String status) { 16 if (status == null) throw new IllegalArgumentException("Status required."); 17 this.status = status; 18 } 19}

Generating getters and setters for every field blindly — which many IDEs do with one click — is not encapsulation. It is the illusion of encapsulation. A setter with no validation is worse than a public field in one way: it adds code without adding protection. Every setter must earn its existence by enforcing a rule.

Mistake 3 — Performing Validation in the Caller Instead of the Class

Java
1// Wrong — validation in caller, not in the class 2public class Main { 3 public static void main(String[] args) { 4 UserProfile user = new UserProfile(); 5 String newEmail = "invalid"; 6 if (newEmail.contains("@")) { // this check belongs in UserProfile.setEmail() 7 user.setEmail(newEmail); 8 } 9 } 10}

When the rule "email must contain @" lives in the caller, every caller must know and apply the rule. A second caller that sets the email without the check creates an invalid UserProfile object. The rule belongs in the setter — once, in the class that owns the data.

Mistake 4 — Making a Class Final Without Making Fields Final

Java
1// Wrong — class is final but fields are mutable 2public final class ProductId { 3 private String id; // not final — can be changed by a setter 4 5 public ProductId(String id) { this.id = id; } 6 7 public String getId() { return id; } 8 public void setId(String id) { this.id = id; } // defeats immutability intent 9}

Making a class final prevents subclassing but does not prevent mutation. For a value object like ProductId that should be immutable, the field must be final and the setter must not exist. final on the class and final on fields serve different purposes — both are needed for true immutability.

Interview Questions

Q1. What is encapsulation in Java and why is it important?

Encapsulation is the OOP principle of bundling data (fields) and the methods that operate on that data into a single class, while restricting direct access to the data from outside. Fields are declared private and access is provided through public methods that can enforce business rules. It is important because it makes the class the single authority on its own state — no external code can corrupt data by bypassing validation. It also allows the internal implementation to change without breaking callers, since they only depend on the methods, not the fields.

Q2. What is the difference between encapsulation and abstraction?

Encapsulation is about protecting data — hiding fields and controlling access through methods. It answers "how is the state protected?" Abstraction is about hiding complexity — showing only what an object does, not how it does it. It answers "what can this object do?" Encapsulation is implemented with private fields and access methods. Abstraction is implemented with interfaces and abstract classes. A class can be fully encapsulated (private fields, controlled access) while still exposing many public methods. Abstraction goes further by hiding even what the implementation class is, through interfaces and polymorphism.

Q3. What is defensive copying and when is it needed?

Defensive copying is the practice of creating copies of mutable objects on the way in (in constructors and setters) and on the way out (in getters) to prevent external code from modifying internal state through a shared reference. It is needed whenever an encapsulated class holds a reference to a mutable object — a List, Date, array, or custom class with setters. Without defensive copying, a caller who received the reference or passed the original to the constructor can modify the object's internal state from outside, bypassing all encapsulation. Java's String avoids this problem by being immutable.

Q4. What makes a Java class truly immutable?

A truly immutable class requires five conditions: all fields must be final; the class itself should be final to prevent subclasses from adding mutable state; all fields must be initialised in the constructor; there must be no setters; and for any mutable object held as a field — like a List or Date — a defensive copy must be made in the constructor and any getter must return a copy or unmodifiable view rather than the direct reference. String, Integer, BigDecimal, and LocalDate in the Java standard library all follow this pattern.

Q5. Why should you not generate getters and setters for every field?

Not every field needs external read or write access. A getter for a field is a promise to external code that the field exists and is readable — changing it later becomes a breaking change. A setter with no validation is equivalent to a public field in terms of protection but adds code without adding safety. Each getter and setter should be justified by a genuine caller need. Fields that are internal bookkeeping, fields that should be immutable after construction, and fields whose values are derived rather than stored often need no getter or setter at all.

Q6. How does encapsulation support the open-closed principle?

When a class is well-encapsulated, its internal implementation can change freely as long as the public method signatures remain the same. If balance is private and accessed only through getBalance(), deposit(), and withdraw(), the class can change how it stores balance — switching from double to BigDecimal for precision, or adding logging to every transaction — without any caller needing to change. The class is open for internal modification (extension) but closed to external change in its API. This is the open-closed principle: callers depend on behaviour, not on implementation.

FAQs

What is encapsulation in Java in simple terms?

Encapsulation is hiding the internal data of an object and only allowing access through controlled methods. Fields are private so no outside code can directly read or change them. Methods are public and contain the rules about what changes are allowed. The class is in complete control of its own state.

What is the difference between encapsulation and data hiding?

Data hiding is a part of encapsulation, not the whole of it. Data hiding means making fields private so external code cannot access them directly. Encapsulation is broader — it also means bundling the data with the behaviour that operates on it, and providing controlled access through methods that enforce business rules. Data hiding is the mechanism; encapsulation is the design principle.

Is a class with all public fields still encapsulated?

No. A class with public fields has no encapsulation — any code anywhere can set any field to any value, with no validation and no control. The class cannot enforce any rules about its own state. Encapsulation specifically requires private fields with controlled access through methods.

Can encapsulation be achieved without getters and setters?

Yes. Encapsulation is about controlled access — not specifically about getters and setters. An immutable class achieves maximum encapsulation with only a constructor and getter methods, and no setters at all. Encapsulation can also be achieved by exposing behaviour methods (like deposit() and withdraw()) rather than raw getters and setters, which is often a cleaner design.

What is the benefit of encapsulation in multi-threaded programs?

In multi-threaded programs, encapsulation allows synchronisation to be centralised inside the class. When all access to shared state goes through controlled methods, synchronisation can be added to those methods — locking is the class's responsibility, not the caller's. Immutable classes, which are the strongest form of encapsulation, are inherently thread-safe and require no synchronisation at all.

Does encapsulation affect performance?

The method call overhead from getters and setters is effectively zero for modern JVMs — the JIT compiler almost always inlines simple accessor methods. Immutable objects may have a slight overhead from creating new objects for every operation, but this is offset by the elimination of synchronisation costs in multi-threaded scenarios. Encapsulation should never be sacrificed for performance without profiling evidence that it is actually the bottleneck.

Summary

Encapsulation is the principle that a class owns and controls its own state. Private fields are the mechanism; validated getters and setters are the controlled access points; immutable classes with final fields are the strongest form. Defensive copying protects internal mutable objects from external modification through shared references.

The discipline of encapsulation compounds over a codebase's lifetime. When business rules live inside the class that owns the data, changing a rule requires updating one place. When internal storage changes, callers do not notice. When bugs appear in state management, the investigation is confined to one class. This is the practical payoff that makes encapsulation worth every line of extra code.

For interviews, be ready to explain the difference between encapsulation and abstraction precisely, demonstrate defensive copying with a mutable field like a List, and describe what makes a class truly immutable with all five conditions. These points consistently appear across both service-based recall questions and product-based design discussions.

What to Read Next

TopicLink
How abstraction hides complexity through interfaces and abstract classesAbstraction →
How access modifiers implement the visibility rules that encapsulation depends onAccess Modifiers →
How immutable class design combines encapsulation with final fieldsImmutable Class →
How inheritance interacts with encapsulated private fields in parent classesInheritance →
How constructors enforce valid initial state as the first encapsulation lineConstructors →
Java Encapsulation | DevStackFlow