Tutorial: Refactor a Frontend Monolith into Feature Slices
Why this tutorial matters
Frontend architecture at scale is mostly about boundaries. A monolith becomes painful when every feature can import every utility, every component depends on global state, and every change risks unrelated screens. This tutorial teaches migration without a rewrite.
You will refactor a mid-level Customer Admin Portal from a folder-by-type structure into bounded feature slices with dependency rules, shared packages, and migration checkpoints.
Starting architecture
src/
components/
hooks/
pages/
services/
store/
utils/
Symptoms:
- shared
utilscontains business logic - components import API clients directly
- pages own too much orchestration
- feature state leaks through global stores
- tests require large app setup
- teams cannot tell who owns a module
Target architecture
src/
app/
routes/
providers/
features/
customers/
model/
api/
ui/
routes/
index.ts
billing/
support/
shared/
ui/
config/
lib/
platform/
http/
auth/
telemetry/
Boundaries:
features/*can importsharedandplatformsharedcannot import features- feature internals are private unless exported from
index.ts - platform code owns cross-cutting concerns
- routes compose features but do not own feature internals
Milestone 1: Inventory imports
Create a dependency map before moving files.
Track:
- top imported modules
- circular dependencies
- feature-to-feature imports
- shared utilities with business language
- components that call APIs directly
- global store slices used across routes
Worksheet:
| Module | Current imports | Hidden owner | Risk |
|---|---|---|---|
utils/customerStatus.ts | customer, billing, dashboard | customers | business logic in shared |
components/CustomerCard.tsx | API, auth, router | customers | too many responsibilities |
Acceptance criteria:
- top 20 shared modules have owners
- circular dependencies are listed
- one migration slice is selected
Milestone 2: Define feature public APIs
For the first feature, create a public barrel:
// features/customers/index.ts
export { CustomerSummaryCard } from "./ui/CustomerSummaryCard";
export { customerRoutes } from "./routes";
export { useCustomerSearch } from "./model/useCustomerSearch";
export type { Customer, CustomerStatus } from "./model/types";
Everything else is private. Other features should not import features/customers/model/internalNormalizeCustomer.
Acceptance criteria:
- route imports go through feature public API
- internal imports are blocked by lint or review
- feature API is documented in one short file
Milestone 3: Move business utilities into feature model
Shared utilities often hide domain ownership.
Before:
shared/utils/status.ts
After:
features/customers/model/customerStatus.ts
Decision rule:
| Utility kind | Location |
|---|---|
| domain language | feature model |
| formatting without domain ownership | shared/lib |
| browser/platform wrapper | platform |
| component primitive | shared/ui |
| API orchestration | feature api or platform http |
Acceptance criteria:
- shared utilities no longer contain customer/billing/support business rules
- feature model has focused unit tests
- no unrelated route imports feature internals
Milestone 4: Isolate API access
Move raw API calls behind feature APIs:
// features/customers/api/customersApi.ts
export async function getCustomer(id: string): Promise<Customer> {
return http.get(`/customers/${id}`);
}
Rules:
- UI components do not construct URLs
- feature API maps transport DTOs to domain types
- auth headers and retries live in platform HTTP
- feature API owns cache keys for feature data
Acceptance criteria:
- no component imports the low-level HTTP client directly
- DTO mapping is tested
- cache keys are colocated with feature API/model
Milestone 5: Replace global state with feature state
Not all state belongs in one global store.
| State | Owner |
|---|---|
| auth session | platform/app |
| route params/search | router |
| server data | query/cache layer |
| form draft | feature UI/model |
| selected tab | route/view state |
| cross-feature workflow | app orchestration |
Refactor one global slice into feature-local state or query cache. Keep global state only where many features truly need the same live state.
Acceptance criteria:
- feature can be tested without full app store
- server data is not duplicated into global state
- local UI state is not stored globally
Milestone 6: Enforce boundaries
Add lightweight enforcement:
- lint rules for restricted imports
- dependency graph check in CI
- feature ownership metadata
- pull request checklist
- architecture decision record for the migration
Example rule:
features/billing/** cannot import features/customers/** except features/customers
public API.
shared/** cannot import features/**.
Acceptance criteria:
- CI fails on known forbidden import
- feature public APIs are stable
- migration exceptions are tracked with expiry date
Milestone 7: Migration rollout
Use a strangler pattern:
- select one route
- move its dominant feature first
- create feature public API
- update route imports
- add boundary checks
- delete old duplicate paths
- repeat for next route
Avoid moving every file first. File movement without ownership rules creates a prettier monolith.
Migration branch strategy
Avoid a giant branch. Use small vertical migrations:
| PR | Change |
|---|---|
| 1 | Add feature folder and boundary lint rule in warning mode |
| 2 | Move customer types and status model |
| 3 | Move customer API and cache keys |
| 4 | Move customer summary UI |
| 5 | Move customer routes to compose public feature API |
| 6 | Turn boundary rule from warning to error |
| 7 | Delete old modules and aliases |
Each PR should preserve behavior. The review should focus on boundaries and ownership, not visual changes.
Dependency rule examples
Use a tool such as ESLint boundaries, dependency-cruiser, Nx rules, or a custom script.
Rules to encode:
shared cannot import app
shared cannot import features
platform cannot import features
features can import shared
features can import platform
features cannot import other feature internals
app can compose features
Allowed:
import { CustomerSummaryCard } from "@/features/customers";
Forbidden:
import { normalizeCustomer } from "@/features/customers/model/internal";
Testing migration
Test at three levels:
| Level | What it proves |
|---|---|
| feature model tests | domain logic works without app |
| feature UI tests | component behavior works with mocked API/model |
| route smoke tests | app composition still works |
Add one architecture test:
No file in shared/** imports from features/**.
No file outside features/customers imports features/customers/** except index.ts.
Refactor scorecard
Track progress:
- number of forbidden imports
- number of files in shared utils
- circular dependency count
- global store consumers
- feature test runtime
- routes migrated
- old paths deleted
The goal is not moving files. The goal is reducing change risk.
Verification checklist
- A feature has a clear public API.
- Shared code contains no feature business rules.
- Feature UI does not call raw APIs.
- Global state is reduced or justified.
- Dependency rules are enforced.
- Tests can run at feature level.
- Old imports are deleted, not aliased forever.
Capstone exercise
Refactor customers end to end:
- customer list route
- customer detail route
- customer summary card
- customer API calls
- customer status utility
- customer search state
- tests and dependency rules
Then write a short ADR: context, chosen structure, alternatives, tradeoffs, migration sequence, and boundary enforcement.
Source lens
This tutorial is synthesized from the book's module architecture, bounded context, dependency governance, shared utility governance, and legacy migration chapters.