skip to content

You've inherited a large, multi-year-old Spring backend organized strictly package-by-layer (`controllers/`, `services/`, `repositories/`, roughly 40 classes in each), with several teams actively shipping features into it. How would you approach migrating it toward package-by-feature, and what structure would you land on internally within each feature package?

level: principalimportance: should knowfreq 40%

answer

  1. incremental, not big-bang
  2. prioritize by change frequency
  3. tests as verification, not manual review
  4. hybrid: layer as internal detail of feature
  5. keep common/ small

basics

~20 s

Don't do a giant rewrite. Move one feature at a time into its own folder while the app keeps running, starting with the feature that changes most often. Inside each feature folder, still keep some internal grouping (like controller/service/repo) so it's not one giant flat pile of files.

solid answer

~60 s

A big-bang restructuring of a live, multi-team codebase is high-risk for low immediate value, so I'd migrate incrementally, feature by feature, prioritized by which features change most often, since that's where locality-of-change pain is worst and where the migration pays off fastest. For each feature: create the new `orders/` package, move `OrderController`, `OrderService`, `OrderRepository`, and related classes into it, update imports, and lean on the compiler/type checker plus the test suite to catch breakage rather than manual verification. Inside the feature package I'd still keep a lightweight internal split - sub-packages or clear file naming for controller/service/repository/dto - so a feature with, say, fifteen classes isn't a flat unsorted pile; the layer distinction becomes an internal implementation detail of the feature rather than the top-level organizing principle. I'd also mark implementation classes like the repository package-private where the language supports it, so other features are structurally prevented from bypassing the service layer, and keep a `common/` or `shared/` package for genuinely cross-feature utilities, kept intentionally small to avoid becoming a dumping ground.

go deeper

for a junior

Isn't expected to own this decision, but should recognize that moving everything at once is risky and that some incremental approach is safer.

for a middle

Should suggest doing it feature-by-feature and recognize the need for tests to verify correctness after moving files.

for a senior

Should give concrete prioritization criteria, a mechanical move-and-verify process, and describe a sensible internal structure for the migrated feature packages.

for a principal

Should address multi-team coordination risk, automated boundary enforcement, a way to track migration completion so it doesn't stall, and the common-package erosion risk - i.e., own the migration as an organizational, not just technical, effort.

## Refactoring under fire Migrating a live, multi-team, package-by-layer codebase to package-by-feature is fundamentally a refactoring-under-fire problem: the code has to keep shipping and working correctly throughout, several teams are touching it concurrently, and a wrong move can produce merge chaos worse than the problem being solved. The right approach treats it as an **incremental, reversible, low-risk migration** rather than a rewrite, and the internal target structure matters as much as the migration mechanics. ## Start with prioritization, not mechanics Start with prioritization, not mechanics. Not every feature needs to move at once, and moving them all at once is exactly the big-bang risk to avoid. The features worth moving first are the ones with the highest current pain: | Signal | What it looks like | |---|---| | highest change frequency | most PRs touching it per month | | highest team-of-record clarity | one team clearly owns it, so there's no cross-team negotiation needed to move it | | lowest current entanglement with other features | fewer cross-cutting calls to untangle | Order matters, because early wins build trust in the approach and surface any tooling gaps (build config, architecture-boundary tests, import-organizing scripts) before they block the harder, more entangled features later. ## The mechanical move for one feature The mechanical migration for one feature is: 1. Create the new package. 2. Physically move `OrderController`, `OrderService`, `OrderRepository`, `OrderDto`, and any validators or mappers into it. 3. Let the IDE's move-refactor or a scripted find-and-replace on imports do the heavy lifting. 4. Then run the full test suite and static-analysis/architecture tests as the actual verification step rather than manual review of every call site — a codebase of this size has too many call sites for manual verification to be trustworthy. Doing the move in small batches, a handful of related classes at a time, rather than one giant commit keeps each PR reviewable and keeps the blast radius of a mistake small. ## Coordinating with teams that are still shipping Concurrency with other teams shipping features is the real operational risk. Moving `OrderService` while another team has an in-flight branch editing `OrderService` in its old location guarantees a painful rebase for them. Mitigate this by: - Communicating the migration schedule ahead of time. - Doing the physical move quickly, ideally same-day rather than a long-lived branch. - Where the team's workflow tolerates it, doing moves during a low-churn window for that specific feature, right after its current sprint's PRs land and before the next batch starts. ## What a feature package looks like inside The internal structure question — what a feature package looks like once it's fully migrated — matters just as much as the migration mechanics, because 'package-by-feature' badly done just relocates the mess: dumping fifteen unsorted classes into a flat `orders/` folder is not an improvement over `controllers/services/repositories` if it's now unclear which of those fifteen files is the entry point versus an internal implementation detail. The common, proven answer is a **hybrid**: layer distinctions survive as an internal, second-level detail inside each feature — `orders/controller`, `orders/service`, `orders/repository`, or equivalently just clear file suffixes if the feature is small enough not to need sub-packages — so a newcomer to the `orders` feature specifically still gets the layer-based navigational aid without that structure being what organizes the whole codebase. ## Two decisions that keep the boundary real Two further structural decisions round this out at scale. 1. **First, use language-level visibility** to enforce the boundary you're building, not just convention: mark `OrderRepository` and other pure-implementation classes package-private, or module-scoped where the language supports it, so nothing outside `orders` can reach past `OrderService`, and add an automated architecture test that fails the build if a forbidden cross-feature import appears, because without automated enforcement, boundaries erode back into a tangle within a year regardless of how clean the initial migration was. 2. **Second, keep a `common/` or `shared/` package deliberately small** and reviewed carefully, since it's the one place the old scattering problem can quietly regrow: anything that doesn't obviously belong to a single feature gets dumped there by default unless someone actively pushes back, and an unchecked `common/` package eventually becomes a second, unstructured `services/`-style grab-bag hiding inside an otherwise well-organized feature layout.

  • How would you prevent the migration from stalling halfway, with half the codebase in feature packages and half still layer-based?
    Treat the migration as a tracked, time-boxed backlog item with a visible completion metric, such as percentage of features migrated or count of classes remaining in the old layer packages, not an open-ended 'as we touch it' effort, since the latter tends to stall once the easy, high-traffic features are done and only awkward, tangled ones remain. Pairing the migration with an automated architecture test that already enforces the target boundaries for migrated features creates pressure to finish, because leaving old code half-migrated becomes visibly inconsistent every time someone runs the check.
  • What tooling would you rely on to catch cross-feature boundary violations automatically once the migration is done?
    Architecture-verification rules, such as ArchUnit-style checks or a framework's built-in module verification, that assert a feature package's classes may not be imported outside that package except through a designated public API class, run as part of the normal CI test suite so a violation fails the build rather than relying on manual code review to catch it. This is what keeps the boundary real over time rather than degrading back into implicit coupling once the initial migration effort's attention moves elsewhere.

It's like renovating a hospital wing by wing while it stays open - you don't close the whole building at once; you move one department's equipment and staff into its new wing, verify it works, then move the next, and you keep an emergency/shared-services corridor small and monitored so it doesn't turn into general storage for everything nobody wants to categorize.

saying these in an interview costs you the question

  • Proposes a single big-bang rewrite/branch for the whole codebase
  • Has no prioritization criteria for which feature to migrate first
  • Plans to verify the migration by manual review alone with no automated test/architecture-check safety net
  • Doesn't mention the risk of an unchecked common/shared package becoming a new dumping ground
  • Treats the migration as purely mechanical file-moving with no plan for the internal structure of each feature package afterward

context