Skip to main content

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 utils contains 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 import shared and platform
  • shared cannot 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:

ModuleCurrent importsHidden ownerRisk
utils/customerStatus.tscustomer, billing, dashboardcustomersbusiness logic in shared
components/CustomerCard.tsxAPI, auth, routercustomerstoo 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 kindLocation
domain languagefeature model
formatting without domain ownershipshared/lib
browser/platform wrapperplatform
component primitiveshared/ui
API orchestrationfeature 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.

StateOwner
auth sessionplatform/app
route params/searchrouter
server dataquery/cache layer
form draftfeature UI/model
selected tabroute/view state
cross-feature workflowapp 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:

  1. select one route
  2. move its dominant feature first
  3. create feature public API
  4. update route imports
  5. add boundary checks
  6. delete old duplicate paths
  7. 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:

PRChange
1Add feature folder and boundary lint rule in warning mode
2Move customer types and status model
3Move customer API and cache keys
4Move customer summary UI
5Move customer routes to compose public feature API
6Turn boundary rule from warning to error
7Delete 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:

LevelWhat it proves
feature model testsdomain logic works without app
feature UI testscomponent behavior works with mocked API/model
route smoke testsapp 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.