skip to content

When does a project's hierarchical path stop matching its directory layout, and how do you handle it deliberately?

level: seniorimportance: should knowfreq 32%

answer

  1. path = logical, projectDir = physical
  2. override in settings.gradle.kts
  3. project(':a:b').projectDir = file(...)
  4. path stays stable, files relocate
  5. cost: discoverability/onboarding

basics

~20 s

The path is logical; the directory is physical. By default ':services:auth' maps to services/auth, but you can remap projectDir in settings.gradle.kts so the same path lives anywhere on disk, decoupling logical structure from physical layout.

solid answer

~50 s

A hierarchical path like `:services:auth` is a **logical** identifier; the directory it maps to is a separate, **physical** concern. By convention Gradle derives the directory from the path (`services/auth` under the root), but the two are independent. In `settings.gradle.kts` you can override the directory: `project(":services:auth").projectDir = file("legacy/authentication")`. The project's path, name, dependency references, and task addressing all stay `:services:auth` regardless of where the files actually live. This decoupling is useful when migrating legacy layouts, when you want a clean logical grouping (`:services:*`) over a messy historical folder structure, or when a flat physical layout (via `includeFlat`-style overrides) is preferred while keeping nested logical paths. The trade-off is discoverability: developers expect path to mirror directory, so deviations should be documented and consistent. You can also override the build file name per project with `project(":services:auth").buildFileName`, though that's rarely advisable.

code

kotlin · 10 lines
kotlin
// settings.gradle.kts
rootProject.name = "platform"
include(":services:auth")

// Logical path stays :services:auth, files live elsewhere
project(":services:auth").projectDir = file("legacy/authentication-service")

// Everywhere else, references are unchanged:
// implementation(project(":services:auth"))
// ./gradlew :services:auth:test

go deeper

for a junior

Know that by default :a:b maps to directory a/b, and that this is the normal case.

for a middle

Explain that projectDir can be overridden in settings so the path stays while files move.

for a senior

Reason about when decoupling is justified (legacy migration, flat-physical/nested-logical) and the discoverability trade-offs.

for a principal

Treat the path as a stable inter-module contract and govern projectDir overrides as documented, sparing exceptions to keep layouts navigable across the org.

## Logical path vs physical directory Two orthogonal things: - **Path** — `:services:auth`, the node's position in the project tree; used for dependencies, configuration, and task invocation. - **projectDir** — the filesystem folder holding that project's sources and build script. By default Gradle computes `projectDir` from the path: each colon becomes a directory separator, rooted at the build root. So `:services:auth` → `<root>/services/auth`. ## Breaking the default mapping You override the directory in `settings.gradle.kts`, where the project tree is being assembled: ```kotlin include(":services:auth") project(":services:auth").projectDir = file("legacy/authentication-service") ``` Now the **path stays** `:services:auth` — every `project(":services:auth")`, `:services:auth:test`, and `implementation(project(":services:auth"))` is unchanged — but the files live in `legacy/authentication-service`. The logical contract is stable while the physical location floats. ## When you'd deliberately do this 1. **Legacy migration** — impose a clean `:domain:*` / `:services:*` logical hierarchy on top of an inherited, inconsistent directory tree without a risky mass move. 2. **Flat physical, nested logical** — keep all modules in a single flat directory for tooling reasons while still grouping them logically by path (related to `includeFlat`, but done per-project with `projectDir`). 3. **Co-locating with non-Gradle assets** — point a project at a directory shaped by another tool. ## Costs and conventions - **Discoverability** — engineers assume path mirrors directory. Surprising mappings slow onboarding; document them and keep them consistent. - **buildFileName override** — `project(":services:auth").buildFileName = "auth.gradle.kts"` is possible but fights conventions; avoid unless forced. - **Tooling** — IDE import and some plugins assume the conventional mapping; verify they cope with overrides. ## Verifying the mapping At configuration time, `project(":services:auth").projectDir` reports the resolved physical directory, and `.path` reports the logical path — useful for asserting layout in a settings or convention script. ## Principle Treat the path as the stable public contract (what other modules and CI reference) and the directory as an implementation detail you may relocate — but only with clear, documented intent.

  • If you remap projectDir, do you need to change project dependency declarations?
    No. Dependencies, task paths, and configuration all key off the logical path (:services:auth), which is unchanged by a projectDir override.
  • What's the main downside of decoupling path from directory?
    Discoverability — engineers expect path to mirror the folder. Non-obvious mappings hurt onboarding and can confuse IDE import/plugins, so document and minimize them.
  • Where must the projectDir override be declared?
    In settings.gradle.kts, where the project tree is assembled — after the corresponding include for that path.

Like a URL route mapped to a controller: the route (path) is the stable public contract; the file backing it (directory) can be moved without changing the route.

saying these in an interview costs you the question

  • Claiming you must rename the path or change dependencies when you relocate a project's directory.
  • Overriding projectDir/buildFileName casually without documenting it, harming team discoverability.

context