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
privateso external code cannot access them directly - ›Controlled access — providing
publicmethods (getters and setters) that enforce rules about how data is read and modified
A well-encapsulated class is one where:
- ›All fields are
private - ›Read access is provided through getter methods when needed
- ›Write access is provided through setter methods with validation when needed
- ›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:
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 stateNow consider with encapsulation:
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.
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}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.
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}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.
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}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.
| Aspect | Encapsulation | Abstraction |
|---|---|---|
| Core idea | Hiding data and implementation details | Hiding complexity by showing only essential behaviour |
| Focus | How state is protected inside a class | What interface is presented to the outside |
| Implemented using | private fields, getters, setters | Abstract classes, interfaces |
| Answers the question | "How is the data protected?" | "What can the object do?" |
| Level | Class-level data protection | System-level design principle |
| Example | private double balance with deposit() and withdraw() | PaymentGateway interface hiding Razorpay vs PayU implementation |
| Runtime behaviour | Enforced by access modifiers at compile time | Enforced 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
| Aspect | Getter | Setter |
|---|---|---|
| Purpose | Read a field value | Write a field value with validation |
| Returns | The field value (or a copy for mutable types) | void (or the object for chaining) |
| Validation | Rarely — just returns | Essential — validates before setting |
| Required | Only when callers genuinely need to read it | Only when callers are allowed to change it |
| Not every field needs one | Yes — provide only what is necessary | Yes — fewer setters = stronger encapsulation |
| Immutable class | Getters only — no setters | No 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
| Aspect | Mutable Class | Immutable Class |
|---|---|---|
| Fields | Can change after construction | Cannot change — final |
| Setters | Present | None |
| Thread safety | Requires synchronisation | Inherently thread-safe |
| Defensive copy on return | Required for mutable fields | Not needed — fields cannot change |
| Equality | May change over time | Stable — safe to use in HashMap keys |
| Example | UserProfile with setters | Money, String, LocalDate |
| Operations | Modify state in place | Return new objects |
| Caching | Risky — cached value may go stale | Safe — 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
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}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}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
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
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
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
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
| Topic | Link |
|---|---|
| How abstraction hides complexity through interfaces and abstract classes | Abstraction → |
| How access modifiers implement the visibility rules that encapsulation depends on | Access Modifiers → |
| How immutable class design combines encapsulation with final fields | Immutable Class → |
| How inheritance interacts with encapsulated private fields in parent classes | Inheritance → |
| How constructors enforce valid initial state as the first encapsulation line | Constructors → |