skip to content

Internal Package Boundaries & Encapsulation

Boundaries only hold if something enforces them: visibility modifiers, internal packages, module-info declarations, and architecture tests with ArchUnit or Spring Modulith that fail the build on an illegal dependency. Convention alone always erodes, which is the point interviewers want made.

part ofSoftware design & architectureoverview, primer and where to startread it →
on this pageshow

questions

6

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

open as a page

Kotlin adds an `internal` visibility modifier that Java doesn't have as a keyword. What is `internal` scoped to, and how does that differ from Java's package-private default access?

level: middleimportance: must knowfreq 55%

basics

~20 s

In Kotlin, internal means 'visible anywhere inside this compiled module (like one Gradle project or JAR), but hidden outside it.' Java's package-private is scoped to a single package folder instead, which is a much smaller boundary.

open as a page

A team wants to guarantee that classes in a controller package never call classes in a repository package directly, skipping the service layer, and that this is caught automatically if someone violates it. Visibility modifiers alone can't express this since both packages are legitimately public within the same module. How would an ArchUnit rule enforce this, and why is a runnable test needed here instead of relying on code review?

level: seniorimportance: must knowfreq 50%

basics

~20 s

ArchUnit is a testing library that reads your compiled Java/Kotlin code and lets you write rules like 'classes in the controller package must not access classes in the repository package.' You put that rule in a normal unit test, so if someone writes code that breaks the layering, the test fails and the build breaks — catching it automatically instead of hoping a reviewer notices.

open as a page

Before Java 9, if a library's JAR was on the classpath, every public class in every package it contained was callable by consumers — including 'internal' implementation packages the library author never intended to expose (a real historical example: sun.misc.Unsafe). What does the Java Platform Module System (JPMS) change with module-info.java, and what do the exports and opens directives actually control?

level: middleimportance: should knowfreq 35%

basics

~20 s

JPMS lets a JAR declare, in a module-info.java file, exactly which packages it shares with the outside world using exports. Any public class in a package NOT listed stays hidden from other modules even though it's technically public, so the compiler and the JVM both block access from outside.

open as a page

Many codebases use a naming convention — an internal sub-package, which Go formalizes as a compiler-enforced rule for any package under a directory literally named internal/, or an impl/internal suffix — to mark implementation packages that outside code shouldn't import. In a language like Java or Kotlin, where the compiler does NOT understand this convention the way Go's does, why do teams still use it, and what has to back it up for the boundary to actually hold?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Naming a package internal tells other developers 'don't import this' even though the compiler won't stop them — Java and Kotlin, unlike Go, don't give that folder name any special meaning. For the rule to actually be enforced, teams add automated checks like ArchUnit tests that fail the build if something outside the boundary imports from an internal package.

open as a page

A platform team is choosing how to enforce module boundaries across a large codebase: JVM visibility modifiers, compile-time; an architecture-test library like ArchUnit or Spring Modulith's ApplicationModules.verify(), test-time static analysis; or the Java Platform Module System, JPMS, runtime strong encapsulation. What does each actually guarantee, at what point in the pipeline does each one fail loudly, and what's the cost of over-relying on any single one?

level: principalimportance: should knowfreq 25%

basics

~30 s

Visibility modifiers stop bad code from even compiling, but only within one package or module. Architecture tests catch bigger-picture rule violations, like 'don't call the database from the web layer,' when you run your test suite. JPMS enforces boundaries at runtime for the whole application, even against tricks like reflection, but it's the most work to set up. Most teams use compiler visibility plus architecture tests, and skip JPMS unless they really need its strength.

open as a page