Packages, access and conventions
Access modifiers as an API decision, package-private as a design tool, static imports, and the naming a reviewer expects.
Access modifiers look like a syntax detail. They are an API decision: every public you write is a promise to every other class that this member exists, does what it does, and will keep doing it. The fewer promises a class makes, the freer you are to change it. This lesson is about making as few as possible, about who enforces them — the compiler, then the JVM, and in later releases the module system — and about the conventions that let a reviewer read your code without a map.
Packages
A package is a namespace and an access boundary. com.shop.billing groups classes and lets them see each other's package-private members. The reverse-domain convention exists to avoid collisions; the more useful convention is package by feature: com.shop.billing, com.shop.catalog, com.shop.shipping, each holding its controller, service, repository and model. Packaging by layer — controllers, services, repositories — puts every feature's pieces in three different places and makes package-private useless, because a service must be public for its controller in another package to reach it.
A package is not a tree. com.shop and com.shop.billing are unrelated as far as access is concerned; a class in the parent package has no special view of the child. And a package is defined per class loader, so com.shop.billing in your jar and com.shop.billing in another jar loaded by a different loader are two runtime packages that cannot see each other's package-private members, which is how the module system detects "split packages" and refuses them.
The four access levels
| Modifier | Visible to |
|---|---|
private | the declaring class only (including nested classes) |
| (none) — package-private | the declaring package |
protected | the package, plus subclasses anywhere |
public | everyone |
Choose the narrowest that works. Fields are private, almost without exception. Helper classes are package-private. Methods a subclass must override are protected; methods a subclass may call are usually a mistake waiting to become protected. public is for the API.
Package-private is a design tool. A class with no modifier is invisible outside its package. In a package-by-feature layout, that means BillingService can be the only public type in com.shop.billing, with InvoiceCalculator, TaxRules and InvoiceRepository hidden behind it. The rest of the application cannot depend on them, so they can be renamed, split or deleted freely. Spring finds package-private @Component classes without complaint.
Under the hood: who enforces access, and when
Access is checked three times, by three different parties, and knowing which one caught you tells you what happened:
javac, at compile time. The familiar error:InvoiceCalculator has private access. This is the only check most code ever meets, and it is the one a compiler for another language, or a decompiled-and-edited class, can skip.- The JVM, at link time. When a method call or field access is resolved (JVMS §5.4.4), the JVM checks the access flags in the class file. A class compiled against a
publicmethod that was later madeprivatefails at first use withIllegalAccessError, not at compile time, because it was never recompiled. This is a binary-compatibility failure, and it is why narrowing access on a published library is a breaking change. It takes four commands to see:
$ javac -d v1 Lib.java && javac -d v1 -cp v1 App.java
$ java -cp v1 App
hello
$ # greet() is now private; recompile Lib alone, leave App.class untouched
$ javac -d v2 Lib.java && cp v1/App.class v2/
$ java -cp v2 App
Exception in thread "main" java.lang.IllegalAccessError:
tried to access method Lib.greet()Ljava/lang/String; from class AppNothing was recompiled on the caller's side, so nothing warned anybody. In a real system App is somebody else's service and Lib is the jar you just published a patch release of.
3. The module system, at run time — added in Java 9, and so not a check that exists on this baseline. Where it applies, a package its module does not export is inaccessible even to a public class. See the section below.
So on Java 8 there are two checks, and the pair is the useful thing to hold: javac refuses what you compile, and the JVM refuses what you did not recompile.
Nested classes are one more case, and the mechanism is visible. A private member of an outer class is accessible from its inner classes and vice versa — but the JVM has no notion of "these two classes are family", so javac bridges it with a synthetic method:
private int secret;
static int access$000(Nest);Three things follow, and all of them surprise people. Reading secret from the inner class is a method call, not a field access. That generated method is static and package-private, so the real access surface of the class is wider than its source suggests — anything in the package can call access$000. And it appears in stack traces and coverage reports as a method nobody wrote.
A later Java records the relationship in the class file instead, as nestmates, and lets the JVM allow the access directly with no bridge. On this baseline the bridge is what you get, and javap -p on your own classes is how to see it.
protected has a rule that catches people: a subclass in another package can access a protected member only through a reference of its own type (JLS §6.6.2). Inside class Cat extends Animal, this.sound and otherCat.sound compile; someAnimal.sound does not, because someAnimal might be a Dog, and Cat has no business with a Dog's protected state.
protected is wider than it looks
protected includes package access. A protected field is visible to every class in the package and to subclasses in any package. It is rarely what you want for fields — a subclass writing to a superclass's field bypasses whatever invariant the superclass maintains. For methods it is the right modifier for a template-method hook: protected abstract Money computeTax(Order o).
On this baseline the only way to restrict who may implement an interface is a package-private constructor on an abstract base class, or nothing at all — an interface is open to anybody who imports it. A final class is the one blunt tool: nobody may extend it. Later releases add sealed types, which name the permitted subtypes explicitly; they are covered with the rest of what those releases added.
Walkthrough: the "safe" refactor that broke production
A shared library's com.acme.util.Ids had public static String next(). A cleanup made it package-private, since the only caller the author could find was in the same package. Tests passed; the library shipped as 2.4.0.
- A downstream service depended on
Ids.next()through a transitive dependency and had been compiled against 2.3.0, where it was public. The service's build did not recompile the service against 2.4.0 because nothing in the service changed; it only bumped the version. - At first request the JVM resolved the
invokestatic Ids.nextreference, read the access flags in the 2.4.0 class file, and threwIllegalAccessError: class com.svc.OrderService tried to access method 'java.lang.String com.acme.util.Ids.next()'. - No compile error anywhere, because nobody compiled the call. The check that caught it was the JVM's, at link time, in production.
The rule: narrowing the access of anything public or protected in a published artifact is a breaking change, the same as removing it, and it needs a major version. japicmp or revapi in the library's build turns this from a production incident into a build failure.
Static imports
import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat;Use them for well-known utilities — Collectors, Assertions, Math — where the class name adds nothing. Do not use them for constants from your own classes, where MAX_RETRIES with no qualifier makes a reader search. Never import static ...* from a class with common names. A static import is resolved at compile time to the same invokestatic; it changes what the reader sees, not what runs.
A third enforcement layer, in later Java
Java 9 added the module system, and with it a third party that can refuse access. A module-info.java declares which packages a jar exports and which it opens to reflection; an unexported package is inaccessible even if its classes are public. It is covered with the rest of what that release added.
One consequence is worth carrying now, because it reaches you even when your own code has no modules: the JDK itself is modular, so a framework that reflects into java.base internals needs --add-opens java.base/java.lang=ALL-UNNAMED on a modern JVM. A stack trace that mentions InaccessibleObjectException is a module boundary, not a bug in your code.
Naming, as a reviewer reads it
UpperCamelCasefor types;lowerCamelCasefor methods, fields and locals;UPPER_SNAKEforstatic finalconstants;lowercasefor packages.- Acronyms as words:
HttpClient,userId, notHTTPClient,userID. - Booleans read as predicates:
isActive,hasStock,canRetry. - No type in the name:
orders, notorderList;byId, notidToOrderMap. - No
Implsuffix. If there is one implementation, drop the interface. If there are two, name them for what makes them different:JdbcOrderRepository,InMemoryOrderRepository. - Constants are for values with a meaning, not for every literal.
MAX_LINE_ITEMS = 500earns a name;ZERO = 0does not. varwhere the right-hand side says the type (var orders = new ArrayList<Order>()), not where it hides it (var result = service.process(x)).
Comments
A comment explains why, never what — the code says what. // increment i is noise; // skip the header row: the export tool always writes one is a comment. Javadoc belongs on public types and methods that other packages call; it should say what the method does, what it returns, what it throws, and any precondition. A package-info.java with one paragraph is the cheapest documentation a feature package can have. Commented-out code is deleted code with extra steps; delete it, git remembers.
Try it yourself
Which lines compile?
package zoo;
public class Animal { protected String sound = "..."; }package farm;
import zoo.Animal;
class Cow extends Animal {
void moo(Cow other, Animal any) {
System.out.println(this.sound); // 1
System.out.println(other.sound); // 2
System.out.println(any.sound); // 3
}
}Answer
1 and 2 compile; 3 does not. From another package, a protected member is accessible only through a reference whose type is the accessing class or a subclass of it (JLS §6.6.2). any is typed Animal, which could be some other subclass whose protected state Cow has no right to. Move Cow into package zoo and all three compile, because protected includes package access.
Diagnose the error
A service starts and throws java.lang.IllegalAccessError: class X tried to access private method Y.m(). It compiled cleanly last week and nothing in the service changed. What happened, and who threw?
Answer
A dependency was upgraded and Y.m() was narrowed from public (or package-private in the same package) to private between versions. The service's class files still reference it, so javac never saw the change; the JVM's link-time access check threw when the call was first resolved. Pin or roll back the dependency, then get the library to treat access narrowing as a major-version change.
Design the package
A payments feature has PaymentController, PaymentService, PaymentRepository, Payment (entity), PaymentDto, StripeClient and PaymentCompleted (event). Which are public?
Answer
PaymentDto and PaymentCompleted, the things other features and the outside world consume; PaymentService if another feature calls it directly, otherwise not. PaymentController is found by Spring's scanning and needs no public. PaymentRepository, Payment and StripeClient are internals; making them package-private is what stops the orders feature from reaching for the repository. Two or three public types out of seven is the shape to aim for.
Misconceptions
- "A subpackage can see its parent's package-private members." Packages are flat for access.
com.shop.billing.taxandcom.shop.billingare strangers. - "Access is a compile-time concept." The JVM checks it again at link time. A narrowed method breaks callers that were never recompiled, and they fail with
IllegalAccessErrorat first use rather than at build time. - "
protectedmeans subclasses only." It means the package and subclasses, and subclasses in other packages only through their own type. - "
publicis free; it just means anyone can call it." It means you can never change it without a major version. Every accidentalpublicis a permanent commitment. - "
protectedmeans subclasses only." It means subclasses and the whole package, and from another package it is restricted by the reference's type. It is almost never the right modifier for a field.
Going deeper
- JLS §6.6 (access control), especially §6.6.2 for the
protectedrule that everyone gets wrong once. - JVMS §5.4.4 (access control at resolution): where
IllegalAccessErrorcomes from. javap -pon your own classes, to see the synthetic accessorsjavacgenerates for nested access — the fastest way to make the section above concrete.- Effective Java item 15, "Minimize the accessibility of classes and members."
japicmpandrevapifor catching binary-incompatible changes in a library's build; ArchUnit for asserting package rules in tests.