skip to content

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%

answer

  1. Go's internal/ is compiler-enforced; Java/Kotlin's isn't
  2. naming alone is a human signal, not a compile-time rule
  3. backed up by ArchUnit or Modulith verify() in CI
  4. public classes still needed for JPA/Spring/Jackson instrumentation
  5. erodes silently without an automated check

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.

solid answer

~60 s

The convention is a readability/intent signal — it tells a human 'this package is an implementation detail' — but on the JVM, internal as a folder name carries zero compiler meaning; any public class inside it is importable from anywhere, unlike Go's internal/ directory, which the go build toolchain refuses to let anything outside the parent tree import. Because JVM languages don't have that native rule, the convention only holds if something else enforces it: package-private/internal visibility on the actual classes where possible, or, when classes must be public for framework reasons such as JPA entities or Spring beans, an architecture test — ArchUnit's noClasses().that().resideOutsidePackage(...) rules or Spring Modulith's ApplicationModules.verify() — that runs in CI and fails the build the moment code outside the intended boundary imports from the internal package. Without that automated backstop, the convention decays: it survives code review discipline for a while and then someone under deadline pressure imports the 'internal' class directly, and now it's a de facto public API nobody can safely change.

go deeper

for a junior

Should recognize 'internal' or 'impl' package names as a hint not to import from there, without needing to know it's unenforced in Java/Kotlin specifically.

for a middle

Should know the convention is not compiler-enforced on the JVM and name at least one tool, like ArchUnit, that can back it up.

for a senior

Should explain why some classes must stay public despite being conceptually internal, and design the combination of convention plus visibility plus architecture test.

for a principal

Should set the org-wide convention, choose the enforcement tooling and its baseline/adoption strategy for legacy code, and justify Go-style compiler enforcement as unavailable on the JVM.

## Compiler-enforced in Go, convention on the JVM Go's toolchain gives special, compiler-enforced meaning to any directory literally named `internal`: a package under `.../internal/...` can only be imported by code that lives in the directory tree rooted at the parent of that internal directory — go build/go vet refuse to compile an import that reaches into someone else's `internal/` tree from outside it, no matter how "public" the identifiers inside look by Go's exported-name capitalization convention. **Java and Kotlin have no equivalent language rule.** A package or folder named `internal`, `impl`, or `_private` is pure convention — the javac/kotlinc compilers attach zero special meaning to that string, so a public class inside a folder called `internal` is exactly as importable from anywhere as a public class inside a folder called `api`. ## Why teams still use it Despite the lack of enforcement, the naming convention persists because it's cheap and high-leverage as a **signal**: a developer opening the codebase, an IDE's autocomplete list, or a reviewer scanning a diff immediately understands "don't import from here" the moment they see `internal` or `impl` in the package path, even before reading any documentation. It costs nothing to adopt and works across language boundaries and tools that don't understand deeper visibility mechanisms. It's also often the only option available for classes that structurally must be public for a framework to work: - JPA entities - Spring `@Component`/`@Service` beans - Jackson-serialized DTOs These frequently can't be package-private/internal because the framework needs to instantiate or proxy them across package/module lines — so the convention becomes the fallback boundary marker precisely where visibility modifiers run out. ## What has to back it up For the convention to survive contact with a growing team and deadline pressure, it needs an **automated enforcer**, because "everyone knows not to import internal packages" degrades exactly like any unenforced rule: it holds during code review by people who remember it, and fails the first time someone new, in a hurry, or unaware of the convention just imports the class because it compiles fine and does what they need. The standard backstop on the JVM is an **architecture test**: - ArchUnit rules such as `noClasses().that().resideOutsideOfPackage("..internal..").should().dependOnClassesThat().resideInAPackage("..internal..")`; - or, for module-level internal boundaries specifically, Spring Modulith's `ApplicationModules.verify()`, which understands that each module's internal sub-package shouldn't be reached from outside that module. These run as ordinary tests in CI, so a violation fails the build at the commit that introduced it, giving the "internal" folder name real teeth it doesn't get from the compiler alone. ## Convention against enforcement | Approach | What it buys | What it costs | |---|---|---| | Convention-only enforcement | essentially free to adopt | fragile over time and team size — it relies entirely on cultural memory and diligent code review, both of which degrade under turnover, growth, and deadline pressure, and neither shows up as a build failure when violated | | Enforced-by-test boundaries | far more durable — a violation cannot merge without either fixing the import or deliberately changing the rule | ongoing maintenance: rules must be kept in sync as the package structure evolves, false positives can block real work until fixed, and teams onboarding the tooling typically need a baseline/freeze mechanism, such as ArchUnit's freeze API, to adopt the rule against a legacy codebase without an immediate flood of failures | ## The erosion when nothing checks The characteristic failure mode without an automated backstop is slow erosion into a **de facto public API**: a developer under deadline pressure imports directly from `payment.internal.RefundCalculator` because it does exactly what they need and nothing stops them; other teams copy that pattern over the following year because "it's already done that way elsewhere"; by the time someone tries to refactor RefundCalculator's signature, half the codebase breaks, and the internal naming has become purely decorative. ## Where it shows up — a Spring Modulith backend A concrete real-world instance of the enforced version: a Spring Modulith backend where each top-level module keeps its implementation classes in an internal sub-package and exposes only a single `*Api` interface at the module root. `ApplicationModules.verify()`, run as part of the standard test suite, walks the compiled module graph and fails the build the moment any module's code imports a class from another module's internal package, turning what would otherwise be a naming convention into an actively-checked architectural contract every commit has to satisfy.

  • Why can't a class inside an 'internal' package always just be marked package-private or Kotlin internal instead of relying purely on the folder name as a convention?
    Often it can, and that's the preferred first line of defense — but some classes must be public for a framework to work with them, such as JPA entities that need public no-arg constructors and accessible getters/setters, or Spring @Component beans that get proxied via dynamic proxies. In those cases the class is structurally forced to be public, so the internal naming convention plus an architecture test is the only boundary available.
  • How does Go's internal/ directory rule differ mechanically from just naming a Java package internal by convention?
    Go's go build toolchain literally parses the import path and rejects the build if code outside the tree rooted at the parent of an internal/ directory tries to import a package under it — identical in strictness to a compile error. Java and Kotlin compilers don't parse package names for special meaning at all, so the equivalent boundary has to be recreated manually with a test tool like ArchUnit encoding the same rule against the compiled class graph.
  • If an ArchUnit rule enforcing the 'internal' convention starts failing on legacy code that already violates it, what's a reasonable way to adopt the rule without blocking all in-flight work?
    Use a freeze/baseline mechanism — ArchUnit's FreezingArchRule records currently-known violations to a baseline file and only fails the build on new violations, letting teams pay down existing debt gradually while preventing the problem from getting worse; this mirrors how static-analysis baselines like a detekt or ktlint baseline are typically adopted against legacy code.

A folder named internal without an enforcing test is like an unlocked door with a 'staff only' sign — most people respect the sign, but nothing physically stops someone in a hurry from walking through, and eventually someone will.

saying these in an interview costs you the question

  • Believes Java or Kotlin gives 'internal' as a folder name any compiler-level meaning
  • Doesn't know Go's internal/ directory IS compiler-enforced, unlike the JVM convention
  • Assumes naming alone is sufficient and no automated check is needed
  • Can't explain why some internal classes are still forced to be public (framework instrumentation)
  • Proposes deleting all architecture tests since 'the naming already makes intent clear'

context