Feature-Sliced Design (lite)
The layered model and one-way dependency rules from FSD, without the full specification overhead.
Context
React projects commonly devolve into a `components/` folder that mixes presentational primitives with feature-specific logic. Hooks import from components, components import from hooks that import from other components. Nothing governs what can import from what, so everything imports from everywhere and the coupling only surfaces when it hurts.
The decision
Apply a lite variant of Feature-Sliced Design (https://feature-sliced.design/overview). The layer names, one-way dependency rule, and barrel index convention come directly from FSD. The full specification (formal slice/segment naming, strict public API enforcement) is not applied. Same idea as DDD-lite: the discipline is real, the ceremony is reduced.
Rationale
- 1
The one-way rule is enforced by convention and code review. Not a linter today, but the rules are explicit and documented. Adding eslint-plugin-boundaries is a one-sprint addition.
- 2
Each layer has a barrel `index.ts`. Consumers import from the barrel, not from deep internal paths. A layer's internal structure can be refactored without touching imports outside it.
- 3
Feature modules own their complete slice: server actions, hooks, and UI. Adding a feature means adding a folder, not touching shared infrastructure.
- 4
Primitive components (`_components/`) are stateless and have no feature dependencies. They can be extracted to a shared package with no refactoring.
- 5
Route segments can own a private `_components/` folder for glue components scoped to that segment. These are components that compose entities and widgets for a specific page but are not reusable outside that route. Next.js treats underscore-prefixed folders as private (not routable), so this works at the framework level. This gives three UI tiers: app-level primitives (`_components/`), route-scoped glue (`(route)/_components/`), and shared compositional blocks (`_widgets/`).
In the codebase
Layer structure and dependency direction
src/app/
_shared/ # cross-cutting content and config
_components/ # primitive, stateless UI. Button, input, card
_providers/ # app-level context. Theme, query client
_entities/ # data access grouped by domain. Actions, schemas, loaders
_features/ # feature modules. Hooks, UI, feature-specific schemas
_widgets/ # compositional blocks that assemble features into page sections
# Dependency rule: layers import from layers below, never above
# _shared -> _components -> _entities -> _features -> _widgets -> pagesEntity + feature split for auth
_entities/identity/
actions/
login.action.ts # 'use server', calls identityService
register.action.ts
index.ts
schema/
login.schema.ts
index.ts
_features/auth/
hooks/
use-login-form.hook.ts # TanStack Form + mutation, calls entity action
use-register-form.hook.ts
index.ts
ui/
login-form.tsx # consumes hook, renders _components
register-form.tsx
index.tsTradeoffs
Dependency direction is explicit and reviewable. A wrong import is visible in a PR.
Without a linter rule, the convention relies on discipline. `eslint-plugin-boundaries` should be added to enforce it automatically.
Features are isolated. You can delete an entire feature folder without breaking others.
The "lite" label means not following the full FSD spec. Route-scoped `_components/` folders and the absence of formal entity UI segments are intentional deviations from the full spec, trading strict layer purity for pragmatic co-location with Next.js routing conventions.