Java · lesson 13 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Encapsulation in Java
Learn how to protect object state with access modifiers, getters, setters, and immutable design — with production-grade Java examples.
By SkillByExample · Updated
Encapsulation means bundling data with the methods that operate on it, and controlling who can
change it. Fields stay private; every change goes through methods that enforce the rules. Each
example is a single file run with the JDK 21 source launcher (java File.java).
The problem: public fields accept anything
public class NoEncapsulation {
public static void main(String[] args) {
BankAccountBad acc = new BankAccountBad();
acc.balance = 100;
acc.balance -= 250; // nothing stops an overdraft
System.out.println("balance = " + acc.balance);
}
}
class BankAccountBad {
public double balance;
}
$ java NoEncapsulation.java
balance = -150.0
Any code anywhere can write any value, so when balance turns out wrong, the culprit could be
any line in the program.
Access modifiers
Start with private and open up only as needed.
| Modifier | Same class | Same package | Subclass | Everywhere |
|---|---|---|---|---|
private | yes | no | no | no |
| (none) | yes | yes | no | no |
protected | yes | yes | yes | no |
public | yes | yes | yes | yes |
Make the field private and the compiler enforces the boundary:
public class PrivateField {
public static void main(String[] args) {
BankAccount acc = new BankAccount();
acc.balance = -150;
}
}
class BankAccount {
private double balance;
}
$ java PrivateField.java
PrivateField.java:4: error: balance has private access in BankAccount
acc.balance = -150;
^
1 error
error: compilation failed
A well-encapsulated class
Validation lives in the methods that change state, and the transaction log is handed out as a read-only view.
import java.util.*;
public class Accounts {
public static void main(String[] args) {
BankAccount acc = new BankAccount("ACC-001", 1000);
acc.deposit(500);
acc.withdraw(200);
System.out.println(acc.balance() + " " + acc.transactions());
try {
acc.withdraw(5000);
} catch (IllegalStateException e) {
System.out.println("Rejected: " + e.getMessage());
}
acc.transactions().add("Deposited 1000000");
}
}
class BankAccount {
private final String id;
private double balance;
private final List<String> log = new ArrayList<>();
BankAccount(String id, double opening) {
if (opening < 0) throw new IllegalArgumentException("negative opening balance");
this.id = id;
this.balance = opening;
}
void deposit(double amt) {
if (amt <= 0) throw new IllegalArgumentException("deposit must be positive");
balance += amt; log.add("Deposited " + amt);
}
void withdraw(double amt) {
if (amt > balance) throw new IllegalStateException("insufficient funds: " + balance);
balance -= amt; log.add("Withdrew " + amt);
}
double balance() { return balance; }
List<String> transactions() { return Collections.unmodifiableList(log); }
}
$ java Accounts.java
1300.0 [Deposited 500.0, Withdrew 200.0]
Rejected: insufficient funds: 1300.0
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1092)
at Accounts.main(Accounts.java:14)
The overdraft was refused and the balance kept its valid value. The caller can read the log but
add on the returned view throws, so the audit trail cannot be forged from outside. There is no
setBalance at all — the only ways to change money are deposit and withdraw.
Validated setters
When a field must be settable, put the rules in the setter and have the constructor call it, so there is one place to get validation right.
public class Employees {
public static void main(String[] args) {
Employee e = new Employee(" Priya ", "PRIYA@Example.com");
System.out.println("[" + e.name() + "] " + e.email());
try {
new Employee("Bob", "bob.example.com");
} catch (IllegalArgumentException ex) {
System.out.println("Rejected: " + ex.getMessage());
}
}
}
class Employee {
private String name, email;
Employee(String name, String email) { setName(name); setEmail(email); }
void setName(String name) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("blank name");
this.name = name.strip();
}
void setEmail(String email) {
if (!email.contains("@")) throw new IllegalArgumentException("invalid email: " + email);
this.email = email.toLowerCase();
}
String name() { return name; }
String email() { return email; }
}
$ java Employees.java
[Priya] priya@example.com
Rejected: invalid email: bob.example.com
A setter can also normalise: the stored name is trimmed and the email lowercased, so every reader sees a consistent value.
Immutable classes
The strongest encapsulation is immutability: no setters, final fields, and operations that
return new objects.
public class MoneyDemo {
public static void main(String[] args) {
Money price = new Money(2999, "USD");
Money total = price.add(price.percent(10));
System.out.println(price + " -> " + total);
price.add(new Money(500, "EUR"));
}
}
final class Money {
private final long cents;
private final String currency;
Money(long cents, String currency) { this.cents = cents; this.currency = currency; }
Money add(Money o) {
if (!currency.equals(o.currency)) throw new IllegalArgumentException("currency mismatch: " + currency + " vs " + o.currency);
return new Money(cents + o.cents, currency);
}
Money percent(int p) { return new Money(cents * p / 100, currency); }
public String toString() { return String.format("%d.%02d %s", cents / 100, cents % 100, currency); }
}
$ java MoneyDemo.java
29.99 USD -> 32.98 USD
Exception in thread "main" java.lang.IllegalArgumentException: currency mismatch: USD vs EUR
at Money.add(MoneyDemo.java:17)
at MoneyDemo.main(MoneyDemo.java:6)
price still prints 29.99 after add and percent, because they return new objects. Storing
whole cents avoids floating-point error; integer division truncates 10% of 2999 cents to 299,
which is why the total is 32.98 rather than 32.99.
Defensive copies
private final stops a field being reassigned, not the object it points at being changed. A
mutable argument has to be copied on the way in and on the way out.
import java.util.Arrays;
public class DefensiveCopy {
public static void main(String[] args) {
int[] grades = {90, 85, 88};
Leaky leaky = new Leaky(grades);
Safe safe = new Safe(grades);
grades[0] = 0; // caller changes its own array afterwards
leaky.grades()[1] = -1; // caller changes the returned array
safe.grades()[1] = -1;
System.out.println("leaky: " + Arrays.toString(leaky.grades()));
System.out.println("safe: " + Arrays.toString(safe.grades()));
}
}
class Leaky {
private final int[] grades;
Leaky(int[] g) { grades = g; }
int[] grades() { return grades; }
}
class Safe {
private final int[] grades;
Safe(int[] g) { grades = g.clone(); }
int[] grades() { return grades.clone(); }
}
$ java DefensiveCopy.java
leaky: [0, -1, 88]
safe: [90, 85, 88]
Leaky shares one array with the outside world, so both outside writes landed inside it. Safe
copied on the way in and on the way out, so its state can only change through its own methods.
For lists, List.copyOf makes an immutable copy in one call.
Summary
| Practice | Why it matters |
|---|---|
private fields | callers can’t bypass the rules |
| validate in constructors and setters | the object is never in an invalid state |
| no setter unless needed | fewer ways for state to go wrong |
| immutable values | safe to share, no surprises |
| defensive copies of mutable fields | final alone doesn’t protect contents |
Practice
- Add
transfer(BankAccount to, double amt)that withdraws from this account and deposits intoto. Show that a failed transfer leaves both balances unchanged.
Solution
public class Transfer {
public static void main(String[] args) {
BankAccount a = new BankAccount(100), b = new BankAccount(50);
a.transfer(b, 30);
System.out.println(a.balance() + " " + b.balance());
try { a.transfer(b, 500); } catch (IllegalStateException e) { System.out.println("Rejected: " + e.getMessage()); }
System.out.println(a.balance() + " " + b.balance());
}
}
class BankAccount {
private double balance;
BankAccount(double opening) { balance = opening; }
void deposit(double amt) { if (amt <= 0) throw new IllegalArgumentException("bad amount"); balance += amt; }
void withdraw(double amt) { if (amt > balance) throw new IllegalStateException("insufficient funds"); balance -= amt; }
void transfer(BankAccount to, double amt) { withdraw(amt); to.deposit(amt); }
double balance() { return balance; }
}
$ java Transfer.java
70.0 80.0
Rejected: insufficient funds
70.0 80.0
withdraw runs first and throws before anything changes. If deposit could fail after a
successful withdraw, you would need to put the money back — that is what transactions are for.
- Tighten
setEmailso"bob@"and"@example.com"are rejected but"bob@example.com"passes.
Solution
public class Emails {
public static void main(String[] args) {
for (String e : new String[] {"bob@example.com", "bob@", "@example.com"}) {
try { System.out.println("ok: " + check(e)); }
catch (IllegalArgumentException ex) { System.out.println("rejected: " + ex.getMessage()); }
}
}
static String check(String email) {
int at = email.indexOf('@');
if (at <= 0 || at == email.length() - 1) throw new IllegalArgumentException(email);
return email.toLowerCase();
}
}
$ java Emails.java
ok: bob@example.com
rejected: bob@
rejected: @example.com
at <= 0 catches a missing @ (-1) and an empty local part (0); the second check catches an
empty domain.
- Write
Studentholding aList<String>of courses so neither the caller’s list nor the returned list can change it.
Solution
import java.util.ArrayList;
import java.util.List;
public class Courses {
public static void main(String[] args) {
List<String> mine = new ArrayList<>(List.of("Math", "Art"));
Student s = new Student(mine);
mine.add("Hacking");
System.out.println(s.courses());
s.courses().add("Hacking");
}
}
class Student {
private final List<String> courses;
Student(List<String> courses) { this.courses = List.copyOf(courses); }
List<String> courses() { return courses; }
}
$ java Courses.java
[Math, Art]
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
at Courses.main(Courses.java:10)
List.copyOf both copies and makes the copy immutable, so it can be returned directly with no
second copy.
- Rewrite
Moneyas arecordwith a compact constructor that rejects negative amounts.
Solution
public class MoneyRecord {
public static void main(String[] args) {
Money m = new Money(1250, "USD");
System.out.println(m + " " + m.cents());
new Money(-1, "USD");
}
}
record Money(long cents, String currency) {
Money {
if (cents < 0) throw new IllegalArgumentException("negative amount: " + cents);
}
}
$ java MoneyRecord.java
Money[cents=1250, currency=USD] 1250
Exception in thread "main" java.lang.IllegalArgumentException: negative amount: -1
at Money.<init>(MoneyRecord.java:11)
at MoneyRecord.main(MoneyRecord.java:5)
A record’s fields are private final and it has no setters, so you get an immutable class in one
line; the compact constructor adds validation before the fields are assigned.