skip to content

Between fully private and fully public, languages offer intermediate visibility levels - protected, package-private, assembly-internal, crate-visible. What unit of code does each of these actually trust, and how would you choose one under a least-privilege rule?

level: middleimportance: must knowfreq 55%

answer

  1. modifier = which trust unit
  2. Java protected includes the package; Kotlin's does not
  3. C#: protected internal = OR, private protected = AND
  4. Java packages do not nest; Rust modules do
  5. tests: InternalsVisibleTo / same package / cfg(test) child

basics

~20 s

Each trusts a different unit: subclasses (protected), one flat package (Java package-private), one compiled assembly (C# internal), one compilation module (Kotlin internal), a crate subtree (Rust pub(crate), pub(super)), one package plus the internal directory rule (Go). Pick the smallest unit that still compiles.

solid answer

~50 s

Least privilege means naming the smallest trust unit that works - and the units are not the same across languages. - **protected diverges sharply.** Java's protected also grants the whole package; C#'s does not, and spells the union `protected internal` and the intersection `private protected`; Kotlin's protected drops package access entirely; Swift has no protected at all, arguing extensions make it incoherent, so you use `fileprivate` or module-level `internal`. - **Package versus assembly.** Java packages are flat: `com.acme.billing.internal` gets nothing from `com.acme.billing`. C# `internal` covers one assembly and needs `InternalsVisibleTo` for tests. Kotlin `internal` is per compilation module and name-mangles on the JVM, so Java code in the same build can still call it. - **Rust** scopes over the module tree - `pub(crate)`, `pub(super)`, `pub(in path)` - so you state the exact scope rather than pick a fixed level. - **Go** uses capitalisation per package, plus an `internal/` directory rule the toolchain enforces on importers.

code

rust · 6 lines
rust
mod billing {
    pub(super) fn rate() -> u32 { 7 }        // visible to the parent only
    mod tax {
        pub(in crate::billing) fn vat() -> u32 { 1 } // visible in billing
    }
}

go deeper

for a junior

Know the ladder in your own language and that the default should be the tightest level that compiles.

for a middle

State the trust unit behind each modifier and name at least one cross-language difference, such as Java's protected including the package or C#'s private protected being an intersection.

for a senior

Argue least privilege at the module boundary, choose the right test affordance instead of widening, and know which levels the compiler actually enforces versus the toolchain or convention.

for a principal

Define the project's visibility policy alongside its module structure - which artefacts are trust units, what is exported from each, and how public API surface is reviewed - since in most languages the module boundary, not the class modifier, is the one that survives refactoring.

## Visibility is a question about trust units Every modifier between `private` and `public` answers the same question - *which body of code do I trust with this?* - and the difference between languages is which bodies of code they let you name. Learning the modifier list of one language teaches you its answers, not the question. ## The subclass unit: protected `protected` names descendants. Four languages disagree on what else it names: - **Java**: protected = subclasses **plus the whole package**. That surprises people; a package-mate that is not a subclass has full access. - **C#**: protected = subclasses only. Because the package/assembly axis is separate, C# spells both combinations explicitly: `protected internal` is the *union* (subclasses **or** same assembly), `private protected` is the *intersection* (subclasses **within** this assembly). That is more precise than anything Java offers. - **Kotlin**: protected = subclasses only, deliberately dropping Java's package grant, and Kotlin has no package-private level at all. - **Swift**: no protected, on purpose. Because any file can add an extension to a type, the language designers argued a subclass-only level would be trivially defeated and confusing; the ladder is `private` (enclosing declaration), `fileprivate`, `internal` (module), `public`, `open`. ## The compilation unit: package, assembly, module, crate - **Java package-private** (the default, no keyword) trusts everything in the same package name. Packages do **not** nest for access: `com.acme.billing.internal` is a completely unrelated package to `com.acme.billing`. Nor is the boundary sealed by default - anyone can declare a class in your package and see your package-private members unless the package is sealed in a JAR or the code is in a named JPMS module. JPMS then adds a second, real layer: only `exports`ed packages are visible outside the module at all. - **C# internal** trusts one assembly - a deployment artefact, usually much bigger than a package. Testing forces the well-known `[assembly: InternalsVisibleTo("MyLib.Tests")]` escape. - **Kotlin internal** trusts one compilation module (a Gradle source set, a Maven module, an IntelliJ module). On the JVM it is compiled to `public` with a mangled name, so Java code in the same build *can* call it - it is a Kotlin-level guarantee, not a bytecode-level one. - **Rust** does not have one intermediate level; it has an address space of them. Items are private to their defining module and its descendants by default, and you widen with `pub(super)`, `pub(crate)` or `pub(in crate::billing)`. You state the trust unit rather than pick from a menu. - **Go** has two levels - capitalised is exported from the package, lowercase is not - plus a toolchain rule: anything under a directory named `internal/` can be imported only by code rooted at that directory's parent. So Go recovers a hierarchical unit outside the type system. ## Choosing under least privilege 1. **Start at the tightest and widen only on a compile error.** Every widening is a promise you now have to keep; the modifier is your record of who could break if you change the member. 2. **Do not widen for tests.** Each ecosystem has an answer that keeps the production visibility intact: C# `InternalsVisibleTo`, Java tests placed in the same package under a separate source root, Rust's `#[cfg(test)] mod tests` child module which can see its parent's private items, Go's in-package `_test.go` files. Reaching for `public` because a test needs a member is the single most common leak. 3. **Beware protected as an accidental API.** In Java, protected on a public non-final class is part of the published surface for every subclasser *and* every package-mate; C#'s `private protected` is the modifier to reach for when you meant "my subclasses inside my own library". 4. **Remember the enforcement layer differs.** Java package-private and Rust module privacy are compiler-enforced; Go's `internal/` rule is toolchain-enforced at import resolution; Kotlin's `internal` is enforced by the Kotlin compiler only. Same intent, different strength. ## The one-line summary There is no universal ladder. `protected` differs on whether the package rides along, the middle level differs on whether the unit is a package, an assembly, a module or a crate subtree, and only Rust lets you name the unit exactly. Least privilege is choosing the smallest unit the language can express, and knowing which of them the compiler actually checks.

  • A test needs a member that is currently private or internal. What do you do in Java, C# and Rust?
    You keep the production visibility and use each ecosystem's test affordance. In Java, put the test class in the same package under a separate test source root, so package-private members are visible without changing the main code. In C#, add [assembly: InternalsVisibleTo("MyLib.Tests")] so internals stay internal to everyone else. In Rust, write a #[cfg(test)] mod tests inside the same file, since a child module can see its parent's private items. Widening the modifier to public is the answer to avoid - it makes the member permanent API for reasons the API never had.
  • Why does Swift have no protected level?
    Because Swift lets any file extend any type, a subclass-only level would be easy to work around and hard to reason about, so the designers chose a lexical ladder instead: private is scoped to the enclosing declaration and its extensions in the same file, fileprivate to the file, internal to the module, public and open to the world. The intended replacement for protected is a module-internal member plus, where subclassing must be constrained, the open/public distinction that controls whether a class can be subclassed outside the module at all.

saying these in an interview costs you the question

  • Assuming Java's protected means subclasses only - it also grants the entire package.
  • Believing Java subpackages get access to a parent package's package-private members.
  • Treating Kotlin's internal as bytecode-level enforcement, when it compiles to public with a mangled name and Java in the same build can call it.
  • Widening a member to public so a test can reach it.
  • Assuming every language has an equivalent of every level - Swift has no protected, Kotlin has no package-private.

context