Loading learning content...
In object-oriented programming, access modifiers are the gatekeepers of your code. They determine who can see, use, or modify the members (attributes and methods) of a class. Among all access modifiers, public is the most permissive—it opens the door wide, allowing access from anywhere in your codebase.
Understanding when and how to use public is fundamental to designing well-structured software. While it offers maximum accessibility, this unrestricted access comes with significant responsibility. Misusing public can lead to tightly coupled, fragile systems where changes in one part unexpectedly break others.
By the end of this page, you will understand: • What the public access modifier means and how it works • The scope and reach of public members across your codebase • When public access is appropriate and when it's dangerous • Best practices for designing public interfaces • Real-world examples of public access in production systems
The public access modifier is the simplest to understand: a public member is accessible from any code, in any class, in any package, from anywhere in the application. There are no restrictions whatsoever.
When you declare a member as public, you're making a contract with the outside world. You're saying: "This member is part of my official interface. Other code can depend on it, call it, and use it freely."
The Universal Visibility Principle:
Public access means:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// File: com/payments/PaymentProcessor.javapackage com.payments; public class PaymentProcessor { // Public constant - accessible everywhere public static final String API_VERSION = "2.1.0"; // Public method - accessible from any code public boolean processPayment(String customerId, double amount) { // Validate and process if (!validateAmount(amount)) { return false; } return executeTransaction(customerId, amount); } // Public getter - part of the class's public API public String getProcessorStatus() { return this.currentStatus; } // Private helper (not public - internal use only) private boolean validateAmount(double amount) { return amount > 0 && amount < 100000; } private boolean executeTransaction(String id, double amount) { // Internal implementation return true; } private String currentStatus = "ACTIVE";} // File: com/ecommerce/CheckoutService.javapackage com.ecommerce; import com.payments.PaymentProcessor; public class CheckoutService { public void checkout(String customerId, double total) { PaymentProcessor processor = new PaymentProcessor(); // Accessing public static field from another package System.out.println("Using API: " + PaymentProcessor.API_VERSION); // Calling public method from another package boolean success = processor.processPayment(customerId, total); // Accessing public getter from another package String status = processor.getProcessorStatus(); // These would cause compile errors - private members: // processor.validateAmount(total); // ERROR: cannot access // processor.currentStatus; // ERROR: cannot access }}While the concept of public access is universal across OOP languages, the syntax varies:
• Java: public void method() — explicit keyword
• C++: public: section in class declaration
• Python: No keyword needed (naming convention: no underscore prefix means public)
• C#: public void Method() — similar to Java
• TypeScript: public keyword (default for class members)
To truly understand public access, we need to visualize its scope. Unlike more restrictive modifiers that limit visibility to certain boundaries, public access transcends all boundaries.
Conceptual Model: Access Boundaries
Think of your codebase as a series of nested circles, each representing a scope of visibility:
| Boundary | Description | Public Access? |
|---|---|---|
| Class Scope | Code within the same class definition | ✅ Yes — always accessible |
| Subclass Scope | Code in classes that inherit from the class | ✅ Yes — inherited and accessible |
| Package/Module Scope | Code in the same package or module | ✅ Yes — directly accessible |
| External Package Scope | Code in different packages entirely | ✅ Yes — accessible with import |
| Different Applications | Code in separate compiled applications | ✅ Yes — if exposed as library/API |
The Ripple Effect of Public Members:
When you make a member public, you're not just making it accessible — you're creating a dependency surface. Every piece of code that uses that public member now depends on it. This has profound implications:
Make members as private as possible, and only as public as necessary. Every public member is a commitment. Once code depends on it, changing it becomes expensive. Start private, promote to public only when there's a clear need.
Despite the risks, public access is essential. Without public members, classes would be islands—unable to communicate or collaborate. The key is knowing when public access serves your design goals.
Legitimate Uses of Public Access:
processPayment(), save(), or sendNotification() are naturally public.Math.PI or HttpStatus.OK) provide named values that clients need.LocalDate.of(), List.of()) must be public for clients to call them.main() method must be public for the JVM to invoke it. Similarly, controller endpoints in web frameworks must be public.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
public class UserAccount { // Public constants - immutable, useful for callers public static final int MAX_USERNAME_LENGTH = 50; public static final int MIN_PASSWORD_LENGTH = 8; // Private internal state - hidden from the world private String username; private String passwordHash; private LocalDateTime createdAt; private boolean isActive; // Public factory method - controlled construction public static UserAccount create(String username, String plainPassword) { if (!isValidUsername(username)) { throw new IllegalArgumentException("Invalid username"); } if (!isValidPassword(plainPassword)) { throw new IllegalArgumentException("Password too weak"); } return new UserAccount(username, hashPassword(plainPassword)); } // Private constructor - forces use of factory private UserAccount(String username, String passwordHash) { this.username = username; this.passwordHash = passwordHash; this.createdAt = LocalDateTime.now(); this.isActive = true; } // Public behavior methods - the class's real API public boolean authenticate(String plainPassword) { return verifyPassword(plainPassword, this.passwordHash); } public void deactivate() { this.isActive = false; } public boolean isActive() { return this.isActive; } // Public getter - read-only access to username public String getUsername() { return this.username; } // Private helpers - internal implementation private static boolean isValidUsername(String username) { return username != null && username.length() <= MAX_USERNAME_LENGTH && username.matches("[a-zA-Z0-9_]+"); } private static boolean isValidPassword(String password) { return password != null && password.length() >= MIN_PASSWORD_LENGTH; } private static String hashPassword(String plain) { // BCrypt hashing in real code return "hashed:" + plain.hashCode(); } private static boolean verifyPassword(String plain, String hash) { return hash.equals(hashPassword(plain)); }}Analyzing the Design:
Notice how little is actually public in the UserAccount class:
This ratio reveals a well-encapsulated design. The public surface is minimal and meaningful—each public member represents a deliberate choice about what the class exposes.
While public methods are essential, public fields are almost always a design mistake. They violate encapsulation by exposing internal state directly, removing the class's ability to control access or validate changes.
The Problem with Public Fields:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ANTI-PATTERN: Public fieldspublic class Rectangle { public double width; // Anyone can modify public double height; // No validation possible} // What can go wrong:Rectangle rect = new Rectangle();rect.width = -50; // Negative width?! No error thrown.rect.height = 0; // Zero height?! Creates invalid rectangle. // Even worse - accidentally assigned in a typo:if (rect.width = 0) { // Assignment, not comparison - silent bug! // This always executes and sets width to 0} // BEST PRACTICE: Encapsulated with accessorspublic class Rectangle { private double width; private double height; public Rectangle(double width, double height) { setWidth(width); setHeight(height); } public void setWidth(double width) { if (width <= 0) { throw new IllegalArgumentException("Width must be positive"); } this.width = width; } public void setHeight(double height) { if (height <= 0) { throw new IllegalArgumentException("Height must be positive"); } this.height = height; } public double getWidth() { return width; } public double getHeight() { return height; } public double getArea() { return width * height; }} // Now invalid states are impossible:Rectangle rect = new Rectangle(10, 20); // Validrect.setWidth(-50); // Throws IllegalArgumentException - caught immediately!Public fields can be acceptable in immutable data transfer objects where fields are final. In Java 16+, records provide this pattern safely:
// Record: public final fields are safe because they're immutable
public record Point(double x, double y) {}
// Usage: can't modify, so public exposure is harmless
Point p = new Point(3, 4);
System.out.println(p.x()); // 3 - can read, can't modify
Modern languages like Kotlin (data classes) and Scala (case classes) have similar constructs.
When classes form inheritance hierarchies, public access takes on additional dimensions. Public members are inherited by subclasses and remain public — they become part of the subclass's interface too.
Key Principles for Public Members in Hierarchies:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Base class with public methodspublic abstract class Animal { // Public method - part of Animal's contract public abstract void speak(); // Public method with implementation public void sleep() { System.out.println("Zzz..."); }} // Subclass inherits public methodspublic class Dog extends Animal { @Override public void speak() { System.out.println("Woof!"); } // sleep() is automatically inherited and public // COMPILE ERROR: Cannot reduce visibility // @Override // protected void sleep() { } // Error: Cannot reduce visibility} // Client code sees all public methodspublic class AnimalShelter { public void nighttime(Animal animal) { // Can call public methods on any Animal animal.speak(); // Works for any subclass animal.sleep(); // Inherited public method works too }} // Interface example - all methods are implicitly publicpublic interface Flyable { void fly(); // Implicitly public abstract default void land() { // Implicitly public System.out.println("Landing..."); }} public class Eagle extends Animal implements Flyable { @Override public void speak() { System.out.println("Screech!"); } @Override public void fly() { // MUST be public System.out.println("Soaring high!"); } // land() is inherited as public from interface}The rule that "you cannot reduce visibility when overriding" connects to the Liskov Substitution Principle (LSP). If client code can call parent.method(), it must also work on any child that substitutes for the parent. Reducing visibility would break this substitutability — code that worked with the parent would suddenly fail with the child.
Designing public APIs for libraries and frameworks requires thinking at a larger scale. Your public members will be used by thousands of developers, making backward compatibility critical.
Principles of Effective Public API Design:
| Principle | Description | Example |
|---|---|---|
| Minimal Surface | Expose only what clients truly need | Java's Collections.unmodifiableList() returns List, not ArrayList |
| Consistent Naming | Use predictable, conventional names | get/set prefixes, plural for collections, -able for capabilities |
| Fail Fast | Validate inputs at the public boundary | Throw IllegalArgumentException on invalid args immediately |
| Defensive Copies | Don't expose mutable internals | Return copies of internal collections, not the collections themselves |
| Document Contracts | Every public member needs clear documentation | Javadoc for methods, exceptions thrown, thread-safety guarantees |
| Version and Deprecate | Signal when public members will change | @Deprecated with migration path before removal |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
/** * A thread-safe repository for storing and retrieving user data. * * <p>All public methods in this class are thread-safe and may be called * from multiple threads concurrently without external synchronization. * * @since 1.0 * @see User * @see UserNotFoundException */public class UserRepository { private final ConcurrentMap<String, User> users = new ConcurrentHashMap<>(); /** * Retrieves a user by their unique identifier. * * @param userId the unique identifier of the user, must not be null * @return the user with the given ID, never null * @throws IllegalArgumentException if userId is null or blank * @throws UserNotFoundException if no user exists with the given ID */ public User findById(String userId) { // Fail fast: validate at the public boundary requireNonBlank(userId, "userId"); User user = users.get(userId); if (user == null) { throw new UserNotFoundException("No user found with ID: " + userId); } // Defensive copy: don't expose the internal User object if mutable return user.copy(); } /** * Returns all users in the repository. * * <p>The returned list is an unmodifiable snapshot; subsequent modifications * to the repository will not affect the returned list. * * @return an unmodifiable list of all users, never null but may be empty */ public List<User> findAll() { // Defensive copy: return immutable snapshot return List.copyOf(users.values()); } /** * Saves a user to the repository. * * <p>If a user with the same ID already exists, it will be replaced. * * @param user the user to save, must not be null * @return the saved user * @throws IllegalArgumentException if user is null or has invalid data */ public User save(User user) { Objects.requireNonNull(user, "user must not be null"); user.validate(); // User validates its own invariants // Defensive copy on input User toStore = user.copy(); users.put(toStore.getId(), toStore); return toStore.copy(); // Return a copy, not the stored instance } /** * @deprecated Use {@link #findById(String)} instead. * This method will be removed in version 2.0. */ @Deprecated(since = "1.5", forRemoval = true) public User getUser(String id) { return findById(id); } // Private helper - not part of public API private static void requireNonBlank(String value, String paramName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(paramName + " must not be blank"); } }}Before making any member public, ask:
✓ Is this truly needed by external code? ✓ Can this change without breaking clients? ✓ Is the contract clearly documented? ✓ Are inputs validated at the boundary? ✓ Am I exposing mutable internals? ✓ Is, there a simpler or smaller interface that works?
If any answer is uncertain, start with package-private or protected access instead.
Even experienced developers make mistakes with public access. Recognizing these patterns helps you avoid them in your own code.
Anti-Patterns to Avoid:
this.items instead of a copy lets callers modify your internal state, causing subtle bugs.calculateInternalHash() clutters the API.ArrayList instead of List couples clients to a specific implementation. Use interface types in public signatures.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// MISTAKE 1: Exposing internal collectionpublic class ShoppingCart { private List<Item> items = new ArrayList<>(); // BAD: Returns the internal list - callers can modify it! public List<Item> getItems() { return items; // Danger: cart.getItems().clear() empties the cart! } // GOOD: Return an unmodifiable view or copy public List<Item> getItems() { return Collections.unmodifiableList(items); }} // MISTAKE 2: Leaking implementation typespublic class UserService { // BAD: Exposes implementation type public ArrayList<User> findActiveUsers() { ... } // GOOD: Use interface types public List<User> findActiveUsers() { ... }} // MISTAKE 3: Public constructor with complex validationpublic class EmailAddress { private final String value; // BAD: Public constructor that throws public EmailAddress(String email) { if (!isValid(email)) { throw new IllegalArgumentException("Invalid email"); } this.value = email; } // GOOD: Private constructor + public factory private EmailAddress(String email) { this.value = email; } public static EmailAddress of(String email) { if (!isValid(email)) { throw new IllegalArgumentException("Invalid email: " + email); } return new EmailAddress(email); } public static Optional<EmailAddress> tryParse(String email) { return isValid(email) ? Optional.of(new EmailAddress(email)) : Optional.empty(); }}We've comprehensively explored the public access modifier — the most permissive but also the most consequential visibility level in object-oriented programming. Let's consolidate the essential knowledge:
What's Next:
Now that we understand public access — the wide-open door — we'll explore its polar opposite: private access. Private members are invisible to the outside world, providing the foundation for encapsulation. Understanding when to use private versus public is one of the most fundamental skills in object-oriented design.
You now have a comprehensive understanding of the public access modifier — its power, its costs, and the principles for using it wisely. Public access forms the visible surface of your classes, and designing that surface well is a key skill of professional software engineering.