Overview
As a codebase grows, every developer steps on every other, CI slows, and nobody owns the messy parts. The structural moves that restore legibility are ownership at the folder level, decomposition by data, enforced module boundaries, sharded builds, and incremental migrations. They build on folder-hierarchy and monorepo.
Decompose by data ownership, not by layer
Splitting by layer (web/, api/, workers/, db/) makes every feature touch every layer, and teams collide on each PR. Give the team that writes a Subscription row the model, the API, the worker, and the migration.
- Weak:
payments-api,payments-worker, andpayments-db-migrationsas jointly owned codebases. - Better: a
payments/module that owns its API, background jobs, and schema.
Team boundaries track data boundaries whether you plan for it or not.
Assign CODEOWNERS per top-level module
# .github/CODEOWNERS
* @org/maintainers
/packages/payments/ @org/payments-team
/packages/identity/ @org/identity-team
/infra/ @org/platform-team
/.github/ @org/platform-team
- One folder, one owning team. Joint ownership diffuses responsibility the way no ownership does.
- Put the default
*first. The last matching pattern wins, so a trailing*would override every specific rule above it. - Owners become required reviewers only with the branch rule that requires code owner review; see github-pr-workflow and github-branch-protection.
Start with a modular monolith
Adopt microservices only for a concrete constraint: an independent deploy cadence, a workload with different scaling, or a regulatory boundary that needs process isolation. Default to one deployable with strict internal modules that import each other only through public interfaces (folder-hierarchy), one migration story, and one deploy. A modular monolith can have a service extracted later; the reverse is much harder.
Enforce module boundaries in tooling
A boundary that lives only in a wiki gets crossed. Make a cross-module import fail CI.
- TypeScript: ESLint
no-restricted-importsoreslint-plugin-boundaries; in Nx,@nx/enforce-module-boundaries. - Python:
import-lintercontracts. - Go:
internal/, enforced by the compiler. - Java: ArchUnit tests.
- Bazel:
visibilityon targets.
Shard builds and tests
- Build. Rebuild only modules whose inputs changed. Turborepo, Nx, Bazel, and Gradle’s build cache all do this. If every PR runs a cold cache, the cache is misconfigured.
- Tests. Split the suite across parallel CI jobs with a matrix (github-actions) and a runner that supports it, such as Jest’s
--shard.pytest-xdistparallelizes within one machine, not across jobs. - Affected-only on PRs. Run tests for modules the diff touches, and the full suite on a schedule.
Keep imports friendly to the IDE
- Import from a configured root (
@org/payments/...), not deep relatives (../../../payments/...). - Avoid barrel files inside modules; see folder-hierarchy.
- Use TypeScript project references so each package builds incrementally (typescript-tsconfig).
- In Python,
from billing import invoicebeatsfrom billing.invoice import *.
Migrate with expand and contract, not a flag day
Land the additive change first (new column or endpoint) and deploy. Move readers and writers over in separate deploys. Remove the old path last. Each step is its own PR with its own owners, flags live in one known place, migrations sit in a numbered directory, and deprecated paths carry a “remove after