Access Modifiers

Controlling visibility — and why getting it wrong costs you

← Back to Index

What Are Access Modifiers?

An access modifier is a keyword that controls which parts of your code can see and use a class, method, or field. Java provides four levels: private, package-private (no keyword), protected, and public. Together they are the primary tool for implementing encapsulation — one of the four pillars of OOP.

The problem they solve: in any non-trivial program, not everything should be accessible from everywhere. If every field and method is public, any part of the codebase can read or modify internal state directly — making it impossible to change the implementation without breaking every caller, and impossible to guarantee that an object is always in a valid state.

// Without access control: anyone can corrupt state
            public class User {
                public String password;  // anyone can read or overwrite this
                public int    age;       // anyone can set age = -999
            }
            
            user.password = "plaintext";  // no hashing, no validation, no control
            user.age = -999;             // completely invalid, silently accepted
            
            // With access control: the class enforces its own rules
            public class User {
                private String passwordHash;  // internal detail, hidden
                private int    age;
            
                public void setPassword(String raw) {
                    this.passwordHash = hash(raw);  // always hashed, no exceptions
                }
                public void setAge(int age) {
                    if (age < 0) throw new IllegalArgumentException("Invalid age");
                    this.age = age;
                }
            }
The principle of least privilege

Every class, method, and field should have the minimum visibility it needs to function. Start with private and widen only when something external genuinely requires access. Every time you make something public, you are making a promise to every caller that this API will remain stable. Narrow visibility = fewer promises = more freedom to refactor.

The Four Access Levels

Access modifiers are the primary tool for encapsulation in Java — they control what parts of your code are visible to other classes. The rule is simple: start with private and widen only when something genuinely needs it. Every unnecessary public method or field is a commitment you'll have to maintain forever.

Modifier Same class Same package Subclass (other pkg) Everywhere
private
(package-private)
protected ✅ *
public

* Protected access in a subclass from a different package only works through inheritance (this/super), not on arbitrary instances of the parent class.

What can have each modifier

Top-level classes: only public or package-private. A private top-level class would be completely unreachable — the compiler rejects it.
Nested classes, constructors, methods, fields: all four modifiers.
Interface methods: implicitly public (unless default, static, or private — private interface methods added in Java 9).

Each Modifier in Detail

private — the default you should reach for

public class BankAccount {
    // Fields: always private — never expose raw state
    private String accountNumber;
    private double balance;
    private String pin;

    // Public API: controlled operations with validation
    public double getBalance() { return balance; }

    public boolean withdraw(double amount, String enteredPin) {
        if (!validatePin(enteredPin) || amount <= 0 || amount > balance) return false;
        balance -= amount;
        logTransaction("WITHDRAW", amount);
        return true;
    }

    // Private helpers: implementation detail, free to change anytime
    private boolean validatePin(String p) { return this.pin.equals(p); }
    private void    logTransaction(String type, double amount) { ... }
}

// ❌ These are compile errors from any other class:
// account.balance = 1_000_000;   private access
// account.validatePin("0000");    private access

package-private (no modifier) — internal implementation

No keyword needed — just omit the modifier. Useful for implementation classes that belong to a module but shouldn't be part of its public API.

// com/app/internal/UserRepository.java
package com.app.internal;

class UserRepository {          // ← no modifier = package-private
    User findById(long id) { ... }
    void save(User user) { ... }
}

// com/app/service/UserService.java — DIFFERENT package
import com.app.internal.UserRepository;  // ❌ ERROR: not visible
Package structure as an API boundary
/*
 *  com.mylib
 *  ├── api/           ← public classes only — this IS your API
 *  │   ├── UserService.java   (public)
 *  │   └── User.java          (public)
 *  └── internal/      ← package-private classes — implementation detail
 *      ├── UserRepository.java   (package-private)
 *      └── CacheManager.java     (package-private)
 *
 *  Library users can only see api/. You can refactor internal/ freely.
 */

protected — for inheritance extension points

protected means same-package access PLUS subclasses in any package. The key nuance: subclass access only works via this/super, not on an arbitrary parent instance.

// package: com.app.model
public class Animal {
    protected String name;      // accessible in subclasses
    private   String internalId;  // NOT accessible even in subclasses

    protected void makeSound() { }  // override hook
}

// package: com.app.pets  (DIFFERENT package)
public class Dog extends Animal {
    @Override
    protected void makeSound() {
        System.out.println(name + " barks");  // ✅ protected field via 'this'
    }

    void weirdAccess(Animal other) {
        // other.name = "x";   ❌ NOT allowed — different pkg, not via inheritance
        this.name = "Rex";    // ✅ allowed — own inherited field
    }
}

public — your API contract

public class UserService {
    public static final int MAX_USERNAME_LENGTH = 50;  // OK: constant

    public User    createUser(String username, String email) { ... }
    public Optional<User> findByUsername(String username) { ... }
    public void     deleteUser(long id) { ... }

    // These are private — users of UserService don't need to know about them
    private void    validateInput(String input) { ... }
    private void    saveToDatabase(User user) { ... }
}
Public mutable fields — almost always wrong
// ❌ Anyone can set password to anything — no validation possible
public class User { public String password; }

// ✅ Controlled: validation lives in one place, changeable without breaking API
public class User {
    private String passwordHash;

    public void setPassword(String raw) {
        if (raw.length() < 8) throw new IllegalArgumentException("Too short");
        this.passwordHash = hash(raw);
    }
}

// ✅ Exception: public static final constants are fine
public static final int MAX_SIZE = 100;

Private Constructors

Two canonical patterns use private constructors to control instantiation:

// PATTERN 1: Utility class — no instances allowed
public final class MathUtils {
    private MathUtils() {
        throw new AssertionError("Utility class");
    }
    public static int square(int n) { return n * n; }
}

// PATTERN 2: Singleton — exactly one instance
public class AppConfig {
    private static final AppConfig INSTANCE = new AppConfig();

    private AppConfig() { }  // nobody else can instantiate

    public static AppConfig getInstance() { return INSTANCE; }
}
// Note: in Spring/CDI apps, singletons are managed by the container — you
// rarely write Singleton pattern manually in modern enterprise Java.

Encapsulation in Practice

Defensive copies for mutable fields

// ❌ BAD: caller can mutate your internal state
public class Team {
    private List<Player> players = new ArrayList<>();

    public List<Player> getPlayers() { return players; }
}
team.getPlayers().clear();  // 💥 wipes the team's roster

// ✅ GOOD option 1: unmodifiable view (no copy overhead)
public List<Player> getPlayers() {
    return Collections.unmodifiableList(players);
}

// ✅ GOOD option 2: defensive copy (caller gets independent list)
public List<Player> getPlayers() {
    return new ArrayList<>(players);
}

// ✅ MODERN: return immutable copy (Java 10+)
public List<Player> getPlayers() {
    return List.copyOf(players);
}

The principle of least privilege — applied

public class OrderProcessor {

    // Fields: always private
    private final OrderRepository repository;
    private final EmailService    emailService;

    // Constructor: public — callers need to build it
    public OrderProcessor(OrderRepository repo, EmailService email) {
        this.repository   = repo;
        this.emailService = email;
    }

    // Public API: what callers are allowed to do
    public Order process(OrderRequest request) {
        validate(request);
        Order order = buildOrder(request);
        repository.save(order);
        emailService.sendConfirmation(order);
        return order;
    }

    // Protected: extension hook for subclasses
    protected void validate(OrderRequest r) {
        if (r.quantity() <= 0) throw new IllegalArgumentException("Bad qty");
    }

    // Private: internal wiring nobody else needs to see
    private Order buildOrder(OrderRequest r) {
        return new Order(r.productId(), r.quantity(), LocalDateTime.now());
    }
}
Quick decision guide
  • Instance fieldsprivate. Always.
  • Helper methodsprivate
  • Extension hooks for subclassesprotected
  • Internal implementation classes → package-private
  • Public API methods / constantspublic
  • If unsure → start private, promote later

Java 9+ Modules: Access Beyond Packages

The Java Platform Module System (JPMS), introduced in Java 9, adds a layer of access control above packages. A module explicitly declares what it exports — even public classes in unexported packages are inaccessible to other modules.

// module-info.java
module com.mylib {
    exports com.mylib.api;          // only api package is accessible
    // com.mylib.internal is NOT exported — invisible to all other modules
    // even if classes inside are public
}

// Another module:
module com.myapp {
    requires com.mylib;
    // Can use com.mylib.api.UserService (public + exported)
    // Cannot use com.mylib.internal.UserRepository (not exported)
}
Reflection and modules

Pre-Java 9, setAccessible(true) could bypass any access modifier — useful for frameworks (Spring, Hibernate) that need to inject fields or proxy classes. With modules, this is restricted by default: a module must explicitly opens a package to allow deep reflection. This is why Spring Boot and Hibernate require specific --add-opens JVM flags or opens declarations when used in a modular application.

Interview Questions

🎓 Junior level

Q: What are the four access modifiers and their scope?
private — declaring class only. Package-private (no keyword) — same package. protected — same package plus subclasses in any package. public — everywhere. "default" is not a keyword; it's the absence of one.

Q: Can a top-level class be private?
No. Top-level classes can only be public or package-private. A private top-level class would be unreachable — the compiler rejects it. Nested classes can be private.

Q: Why should instance variables almost always be private?
Encapsulation: private fields let you change the internal representation without breaking any code that uses the class. If you expose a field directly, any change to its type or name breaks all callers. With a private field and a getter, you can change the implementation while keeping the API stable.

🔥 Senior level

Q: What is the protected access nuance that trips people up?
In a subclass from a different package, you can access a protected member only through this or super — not through an arbitrary reference to the parent class. Example: inside Dog extends Animal, you can read this.name but not someOtherAnimal.name, even though both are Animal. This is the spec — the JLS specifies it explicitly.

Q: Can an overriding method have more restrictive access?
No. The Liskov Substitution Principle requires that a subtype be substitutable for its supertype. If you could narrow public to private, code holding a reference to the supertype would break at runtime. The compiler enforces this: overriding methods must have equal or wider access.

Q: How does the Java 9 module system change access control?
Modules add a layer above packages: even public classes in unexported packages are inaccessible to other modules. This closed-by-default model is why setAccessible(true) via reflection is now restricted — a module must explicitly opens a package for deep reflection. This broke many frameworks (Spring, Hibernate, Jackson) during the Java 9 migration, requiring --add-opens flags as a temporary bridge.

Q: When would you use package-private instead of private?
When you need access from test classes in the same package, or when related classes in a package need to collaborate on internals without exposing that to the world. Example: a UserRepository used only by UserService in the same package. Making it package-private keeps it invisible to the rest of the application while avoiding the overhead of a formal API.