skip to content

In Java, what's the difference between declaring a class `public` versus leaving it with default (package-private) access, and why would a team choose package-private for an internal helper class?

level: juniorimportance: must knowfreq 65%

answer

  1. four levels: private/package-private/protected/public
  2. package-private = no modifier, package-scoped
  3. compiler-enforced, zero runtime cost
  4. reflection bypasses it (setAccessible)
  5. granularity is per-package, all-or-nothing

basics

~20 s

Public means any code anywhere in the program can use the class. Default/package-private means only code living in the same folder (package) can use it. Teams hide helper classes as package-private so other parts of the codebase can't accidentally depend on internal details that might change.

solid answer

~40 s

Java's default (no-modifier) access level restricts visibility to the declaring package — it's the cheapest, zero-tooling form of encapsulation the language gives you. If a class is an implementation detail (a parser, a mapper, a cache) that only its own package needs, leaving it package-private prevents other packages from importing it, so you can rename, delete, or redesign it without a wider blast radius. The trade-off is that it only forms a real boundary if the package itself is a deliberate, cohesive unit — if related classes get scattered across sibling packages for organizational reasons, package-private members become invisible even to code that conceptually belongs together, forcing an unwanted widening to public.

go deeper

for a junior

Should know the four access levels exist and correctly state that package-private is more restrictive than public and enforced by the compiler; doesn't need to discuss reflection or JPMS.

for a middle

Should proactively suggest package-private/internal for implementation classes when designing a feature, and recognize the granularity limitation when two packages need to share an internal type.

for a senior

Should connect visibility modifiers to information-hiding principles, know reflection bypasses them, and articulate when to backstop them with architecture tests rather than relying on visibility alone.

for a principal

Should be able to set an org-wide convention for internal package structure, weigh package-private/internal against JPMS module boundaries for a specific system, and recognize the split-package security implications pre-JPMS.

## The four access levels Java defines four access levels, in increasing order of exposure: | Level | Reach | |---|---| | `private` | the declaring class only | | package-private / default | no modifier — visible to any class whose source file declares the same package, regardless of inheritance | | `protected` | package-private plus visible to subclasses in other packages | | `public` | visible everywhere reachable on the classpath/module path | **Package-private is the only level tied purely to physical file organization** rather than the type hierarchy. When a top-level class, field, method, or constructor has no modifier, `javac` treats any reference from a class outside that exact package as a compile error — no import path resolves it, so the violation is caught before the code ever runs, at zero runtime cost and with no extra tooling. ## Why it exists — information hiding This exists because of **information hiding** (Parnas's principle): a module's public surface should be the minimum needed by its callers, while everything else stays free to change without breaking anyone: - algorithms - data structures - helper classes - caches A package-private helper class (say, a CSV row parser used only by one loader) has exactly one set of callers, all inside the same package, so the author can rename its methods, change its constructor, or delete it outright without a cross-team migration. This is the **cheapest encapsulation mechanism the JVM gives you**: - no config file - no library - no separate build step The compiler enforces it as a side effect of normal compilation. ## The cost — granularity Package-private is all-or-nothing at the package boundary: you can't say "visible to package B but not package C," only "visible to everyone in this exact package or to no one outside it." This bites when a genuinely internal type needs to be shared between two closely related but separately-packaged classes — say `order.internal.PricingEngine` and `payment.internal.DiscountCalculator` both want to use the same internal utility. The choices are then: 1. Make the utility **public**, leaking it to literally everyone, defeating the purpose. 2. Physically **relocate** code into a shared package just to get access, producing a package that exists purely for visibility reasons rather than a coherent design unit. Kotlin's `internal` modifier, scoped to the whole compilation module rather than one package, exists partly to solve this exact granularity mismatch. ## How the boundary erodes In practice, package-private boundaries erode in a few characteristic ways. 1. **First, "temporary" widening.** Under time pressure a developer needing cross-package access marks a class public "just this once," and because nothing forces it back, it becomes permanent, silently-adopted API that later blocks refactoring. 2. **Second, reflection bypass.** Frameworks like Hibernate, Jackson, and Spring routinely call `Field.setAccessible(true)` to read and write package-private and even private fields directly, so package-private is a compile-time social contract between human-written code, not a runtime security boundary — reflective code can always reach in. 3. **Third, pre-JPMS,** package-private wasn't even sealed at the classpath level: two different JARs could declare classes in the same package name, and a maliciously crafted one could add classes to your package and get compiler-level access to your "hidden" members — the split-package problem, one concrete motivation behind JPMS's stronger module-scoped encapsulation. ## Where it shows up — a modular Spring backend A concrete example from a modular Spring backend: a module like `order` exposes exactly one public entry point, an `OrderApi` interface, while its implementation class `OrderService`, its JPA repository, and its internal mapper classes are declared package-private (or, in Kotlin, `internal`) inside `order.internal`. Another module, `payment`, can only call `order` through `OrderApi` — it has no visibility into `OrderService` at all, so the compiler physically prevents `payment` from reaching past the API into order's persistence details. This lets the `order` team change its database schema, its mapper logic, or even swap its persistence technology, as long as OrderApi's contract is preserved. Where compiler visibility alone falls short — some classes must be public for Spring or JPA to instrument them even though they're conceptually internal — teams backstop the convention with an **architecture test**, exactly the gap ArchUnit and `ApplicationModules.verify()` exist to close, which is why package-private visibility and architecture tests are typically used together rather than as substitutes.

  • If package-private is enforced by the compiler, why do frameworks like Hibernate and Spring still manage to access package-private fields and methods at runtime?
    They use Java reflection, specifically Field.setAccessible(true) / Method.setAccessible(true), which historically bypassed access-modifier checks entirely at the JVM level. Package-private only stops ordinary compiled code from referencing a member directly — it was never a JVM-level security boundary until JPMS's strong encapsulation, and even then opens directives explicitly re-permit this exact reflective access for frameworks that need it.
  • What happens if two sibling packages both need the same internal helper class — does package-private force you to make it public?
    Not necessarily public, but you're forced into a trade-off: either widen it to public (leaking it to every consumer, not just the two siblings), move it into a shared parent/common package both can see, or, in Kotlin, use internal instead, which is scoped to the whole compilation module rather than a single package and so is visible to both siblings automatically without going fully public.
  • Does package-private protect a class from being subclassed or instantiated by code outside the package?
    Yes for instantiation via new and for calling package-private constructors/methods — outside code simply can't compile a reference to them. A package-private class itself can still be extended by a subclass declared in the same package, but that subclass can't be referenced by name outside the package either unless it's separately public.

Package-private is like a key that only opens doors within one office suite — anyone inside the suite can walk into any room, but people in other suites down the hall, even in the same building, can't get in without a different key (making the class public).

saying these in an interview costs you the question

  • Says package-private and private mean the same thing
  • Doesn't know package-private is scoped by physical package, not by class hierarchy or ownership
  • Claims package-private is a runtime security guarantee immune to reflection
  • Thinks marking everything public is fine 'because Java doesn't really enforce encapsulation anyway'
  • Can't explain what forces a class to become public when two unrelated packages need to share it

context