skip to content

How would you design a package structure for a large application, and what package-level pitfalls do you watch for?

level: seniorimportance: should knowfreq 40%

answer

  1. Reverse-domain root, then by-feature/bounded-context
  2. High cohesion, low coupling
  3. Package-private = encapsulation; public is a promise
  4. No cycles — dependency graph must be a DAG
  5. Enforce with ArchUnit / JPMS / multi-module builds

basics

~20 s

Group code by feature or layer with a clear reverse-domain root, keep packages cohesive, and avoid two packages depending on each other (cycles). Use package-private visibility to hide internals so other packages only touch a small public surface.

solid answer

~50 s

I start from a reverse-domain root (`com.example.app`) and split below it, usually by feature/bounded context (`...billing`, `...catalog`) and within each by responsibility, rather than one giant by-layer split that scatters a feature across many packages. The goals are high cohesion (a package's types belong together), low coupling (few inbound/outbound dependencies), and an explicit public surface: I make internal types package-private and expose a minimal API, so a package becomes a real encapsulation boundary. The biggest pitfalls are package cycles (A depends on B and B on A), which make code hard to reason about, test, and modularize; god packages that accumulate unrelated classes; and leaking internals by making everything public. I enforce the rules with tooling — ArchUnit or jQAssistant to ban cycles and forbidden dependencies — and, where it pays off, JPMS modules or build-module boundaries to make the API/internal split compiler-enforced.

go deeper

for a junior

Can place classes in sensibly named packages and knows package-private hides things from other packages.

for a middle

Chooses by-feature vs by-layer with reasons and applies package-private to limit a package's public surface.

for a senior

Designs for cohesion/coupling, actively prevents cycles, defines API/internal splits, and enforces rules with ArchUnit.

for a principal

Sets org-wide package/module architecture, aligns packages with bounded contexts, mandates JPMS/multi-module boundaries and CI-enforced dependency rules, and plans migrations to break legacy tangles.

## Why package design matters A **package** groups related types and is also Java's unit of *package-private* visibility (members with no access modifier are visible only within the same package). At small scale any layout works; at large scale, package structure becomes the map of the system and the main lever for controlling dependencies. Two timeless metrics guide it: - **Cohesion** — the degree to which the types in a package belong together (serve one responsibility). High is good. - **Coupling** — how many dependencies cross package boundaries. Low is good. ## By-layer vs by-feature There are two common axes: - **By layer (technical):** `...controller`, `...service`, `...repository`. Simple and familiar, but one feature is smeared across every layer package, and changing that feature touches many packages. It also tends to force everything `public` so layers can call each other. - **By feature / bounded context:** `...billing`, `...catalog`, `...shipping`, each holding its own controller/service/repository (often as sub-packages). This keeps a change local to one package subtree, lets you hide a feature's internals behind a small public API, and aligns packages with domain boundaries (Domain-Driven Design). For large apps, by-feature (optionally with thin layer sub-packages inside each feature) usually wins. A pragmatic structure: ``` com.example.shop ├── billing │ ├── api (public types other features use) │ └── internal (package-private impl) ├── catalog └── shared (cross-cutting value types, used by many) ``` ## The encapsulation lever: package-private Making a type or member **package-private** (no modifier) means only the same package can see it. This lets a package expose a deliberately small **public API** while keeping implementation types invisible to the rest of the app. Treat `public` as a decision, not a default — every public type is a promise other packages may depend on. ## The cardinal pitfall: package cycles A **cycle** is when package A depends on B and B (directly or transitively) depends back on A. Cycles are corrosive: - You can't understand or change one package in isolation — they're effectively one tangled unit. - They block clean layering and make extraction into a separate module/JAR impossible (you can't break the knot). - They complicate testing and class-loading order. The fix is to introduce a direction: extract the shared abstraction into a third package both depend on, apply **dependency inversion** (define an interface in the lower-level package, implement it higher up), or merge packages that are genuinely one concept. The acyclic-dependencies principle says the package dependency graph should be a DAG. ## Other pitfalls - **God / utility packages:** a `util` or `common` package that becomes a dumping ground; it ends up coupled to everything. Prefer cohesive, named packages. - **Over-`public` surfaces:** exposing implementation types as public erodes encapsulation and invites unwanted dependencies. - **Mismatched directory layout:** the package name must mirror directories under the source root; tooling enforces this, but copy-paste can drift. - **Splitting too finely:** dozens of one-class packages add ceremony without cohesion benefit. ## Enforcing the rules Conventions decay without automation: - **ArchUnit** — a Java test library that asserts architecture rules: `noClasses().should().dependOnClassesThat()...`, `slices().should().beFreeOfCycles()`, layered-architecture checks. Run as part of the build so violations fail CI. - **jQAssistant / Sonar** — also detect cycles and forbidden dependencies. - **JPMS (`module-info.java`)** — makes the API/internal split *compiler-enforced*: only `exported` packages are visible outside the module, and the module graph must be acyclic. Heavier-weight; worth it for libraries and large modular apps. - **Multi-module builds (Maven/Gradle)** — physical module boundaries that prevent illegal compile-time dependencies. ## Summary Pick a reverse-domain root; favor by-feature/bounded-context grouping with thin internal layering; maximize cohesion and minimize coupling; expose a small public API and hide the rest with package-private; and ruthlessly avoid package cycles, god packages, and over-exposure — enforcing it all with ArchUnit/JPMS/multi-module builds so the design survives contact with a team.

  • How do you detect and break a package cycle?
    Detect with ArchUnit's `slices().should().beFreeOfCycles()` (or jQAssistant/Sonar). Break it by extracting a shared abstraction into a third package, applying dependency inversion (interface in the lower package, implementation higher), or merging packages that are truly one concept.
  • How does JPMS strengthen package-level encapsulation beyond package-private?
    A module only makes `exported` packages visible to other modules; non-exported packages are inaccessible even if their types are public. So JPMS adds a module-level boundary on top of the package/class access modifiers, compiler- and runtime-enforced, and requires an acyclic module graph.

saying these in an interview costs you the question

  • Treating `public` as the default for every type
  • Tolerating package cycles ('they're harmless')
  • A catch-all `util`/`common` package coupled to everything
  • Pure by-layer split for very large apps, smearing each feature everywhere
  • Assuming conventions hold without automated enforcement

context