Skip to content

Code Organization Options for arda-frontend-app

Analysis of directory structure options that express the functional boundaries identified in the codebase audit — SPA vs. BFF vs. shared, client state vs. server state, domain features vs. infrastructure — and can be enforced with ESLint rules.

src/
├── app/ # Next.js App Router (pages + API routes mixed)
│ ├── api/arda/ # BFF proxy routes (55 route.ts files)
│ ├── items/ # Page components (colocated with pages)
│ ├── kanban/ # Page components
│ └── ... # 15+ page directories
├── components/ # React components (119 files, flat by feature)
│ ├── common/ # Shared layout components
│ ├── items/ # Item-specific components (ItemFormPanel, etc.)
│ ├── scan/ # Scan-specific components
│ ├── settings/ # Settings components
│ ├── table/ # ArdaGrid, column presets
│ ├── ui/ # ShadCN primitives
│ └── ...
├── lib/ # Mixed utilities (37 files, flat)
│ ├── ardaClient.ts # SPA-only: API calls to BFF
│ ├── jwt.ts # Shared: server verification + client decode
│ ├── env.ts # BFF-only: server secrets
│ ├── api-helpers.ts # BFF-only: request parsing
│ ├── mappers/ # SPA-only: type mapping
│ └── validators/ # SPA-only: form validation
├── store/ # Redux Toolkit (33 files)
│ ├── slices/ # State slices
│ ├── thunks/ # Async actions
│ ├── hooks/ # Redux hooks
│ ├── selectors/ # Memoized selectors
│ └── middleware/ # Token refresh middleware
├── contexts/ # React contexts (4 files)
├── hooks/ # Custom hooks (14 files, flat)
├── types/ # TypeScript types (flat)
├── constants/ # Constants and enums
├── mocks/ # MSW setup + handlers + data
├── utils/ # Helper functions
├── lambda/ # Cognito Lambda functions (separate concern)
├── test-utils/ # Test render helpers
└── tests/ # Integration tests
  1. lib/ is a flat bag — server-only (env.ts), client-only (ardaClient.ts), and shared (jwt.ts) modules coexist with no structural distinction. An ESLint rule cannot target “server files in lib” because there’s no directory boundary.

  2. No SPA API functions layer — client-side API calls live in lib/ardaClient.ts (a single 500+ line file mixing all domains). The proposed src/api/ layer from this project has no precedent.

  3. Components are flat by featurecomponents/items/ contains 20+ files mixing page-specific components, cell editors, form panels, and typeahead editors. No distinction between “used on one page” vs. “reusable across features.”

  4. Hooks are flathooks/ has 14 files with no grouping by concern (auth hooks, form hooks, data hooks mixed).

  5. Store mixes server and client statestore/slices/itemsSlice.ts holds both server data (fetched items) and client UI state (column visibility, active tab, drafts). TanStack Query adoption will split this, but the store structure doesn’t express the boundary.

  6. app/ mixes pages and BFF — Next.js App Router forces api/ under app/, but this means page routes and server routes are siblings. No ESLint rule can distinguish “this is a server-only directory” vs. “this is a client page.”


The primary tool. Defines zones (directory-based boundary regions) and rules restricting imports between zones.

// Example: zones definition
settings: {
'boundaries/elements': [
{ type: 'bff', pattern: 'src/server/**' },
{ type: 'spa-api', pattern: 'src/api/**' },
{ type: 'hooks', pattern: 'src/hooks/**' },
{ type: 'store', pattern: 'src/store/**' },
{ type: 'ui', pattern: 'src/components/**' },
{ type: 'types', pattern: 'src/types/**' },
{ type: 'shared', pattern: 'src/shared/**' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
// BFF can import from shared and types, nothing else
{ from: 'bff', allow: ['shared', 'types'] },
// SPA API can import from shared and types
{ from: 'spa-api', allow: ['shared', 'types'] },
// Hooks can import from spa-api, store, shared, types
{ from: 'hooks', allow: ['spa-api', 'store', 'shared', 'types'] },
// UI can import from hooks, store, shared, types — NOT from bff or spa-api
{ from: 'ui', allow: ['hooks', 'store', 'shared', 'types'] },
// Shared can only import from types
{ from: 'shared', allow: ['types'] },
],
}],
}

Requirement: zones must map to directories. This is why the directory structure matters — you can’t define a zone for “server-only files in lib” when they’re mixed with client files.

Blocks specific import patterns anywhere, regardless of directory:

'@typescript-eslint/no-restricted-imports': ['error', {
patterns: [{
group: ['@tanstack/react-query'],
importNamePattern: '^use',
message: 'Import TanStack hooks from @/hooks/, not directly',
}],
}]

Restricts specific source → target path combinations:

'import/no-restricted-paths': ['error', {
zones: [{
target: './src/components/**',
from: './src/server/**',
message: 'Components cannot import server-only code',
}],
}]

Not ESLint — build-time enforcement. Any file that import 'server-only' causes a build error if bundled into client code.


Organize by technical concern at the top level. Each directory is a zone with clear import rules.

src/
├── app/ # Next.js App Router — pages only
│ ├── items/
│ ├── kanban/
│ └── ...
├── server/ # BFF-only code (Zone: bff)
│ ├── routes/ # API route handlers
│ │ ├── arda/items/ # mirrors app/api/arda/items/
│ │ ├── arda/kanban/
│ │ └── storage/ # image upload BFF routes
│ ├── lib/ # Server-only utilities
│ │ ├── env.ts
│ │ ├── jwt-verify.ts
│ │ └── api-helpers.ts
│ └── index.ts # Barrel (import 'server-only')
├── api/ # SPA API functions (Zone: spa-api)
│ ├── image-upload.ts # Plain async fetch functions
│ ├── items.ts # (future: extract from ardaClient)
│ └── index.ts
├── hooks/ # React hooks (Zone: hooks)
│ ├── queries/ # TanStack useQuery wrappers
│ ├── mutations/ # TanStack useMutation wrappers
│ ├── providers/ # FD-01 typed provider implementations
│ └── auth/ # Auth-related hooks
├── store/ # Redux — client state only (Zone: store)
│ ├── slices/
│ ├── hooks/
│ └── middleware/
├── components/ # React components (Zone: ui)
│ ├── common/
│ ├── items/
│ ├── scan/
│ └── ui/ # ShadCN primitives
├── shared/ # Code used by both SPA and BFF (Zone: shared)
│ ├── jwt-decode.ts
│ ├── types/ # Shared TypeScript interfaces
│ └── utils.ts
├── providers/ # React context providers
│ ├── query-provider.tsx
│ ├── cdn-cookie-provider.tsx
│ └── redux-provider.tsx
├── mocks/ # MSW setup + handlers
├── test-utils/ # Test helpers
└── constants/ # Constants and enums

ESLint zones:

ZoneDirectoryCan import from
bffsrc/server/shared, types
spa-apisrc/api/shared, types
hookssrc/hooks/spa-api, store, shared, types
storesrc/store/shared, types
uisrc/components/, src/providers/hooks, store, shared, types
sharedsrc/shared/types only
pagessrc/app/ (non-api)ui, hooks, providers, store, shared, types

Next.js constraint: App Router requires API routes under src/app/api/. The src/server/routes/ directory would hold the handler logic, but the actual route.ts files in src/app/api/ would be thin re-exports:

// src/app/api/image-upload/route.ts — thin re-export
export { POST } from '@/server/routes/storage/image-upload';

Pros:

  • Clear zones map 1:1 to directories — ESLint rules are straightforward
  • BFF code is physically separated from SPA code
  • New layer (src/api/) has a dedicated home
  • import 'server-only' in src/server/index.ts catches leaks at build time

Cons:

  • Next.js forces app/api/ for routing, so server routes need thin re-exports
  • Feature code is spread across directories (items logic in components/items/, hooks/providers/, api/items.ts, server/routes/arda/items/)
  • Moderate migration effort (~65 files moved + ~200 import updates)

Organize by domain feature. Each feature contains its own components, hooks, API functions, and types. Shared infrastructure stays horizontal.

src/
├── app/ # Next.js App Router — pages + API routes
│ ├── api/arda/ # BFF routes (thin, delegate to features)
│ └── items/ # Page routes
├── features/ # Domain features (Zone per feature)
│ ├── items/
│ │ ├── components/ # ItemFormPanel, cell editors, etc.
│ │ ├── hooks/ # useItemQuery, useItemMutation, providers
│ │ ├── api/ # SPA API functions for items
│ │ ├── types/ # Item-specific types
│ │ └── index.ts # Feature barrel
│ ├── image-upload/
│ │ ├── components/ # (wiring hooks, not design system components)
│ │ ├── hooks/ # useImageUpload, useCheckReachability
│ │ ├── api/ # SPA API functions for image upload
│ │ └── types/
│ ├── kanban/
│ ├── scan/
│ └── auth/
├── server/ # BFF infrastructure (Zone: bff)
│ ├── lib/
│ │ ├── env.ts
│ │ ├── jwt-verify.ts
│ │ └── api-helpers.ts
│ └── middleware/
├── shared/ # Cross-feature shared code (Zone: shared)
│ ├── lib/
│ │ ├── jwt-decode.ts
│ │ └── utils.ts
│ ├── types/
│ ├── hooks/ # Generic hooks (useDebounce, etc.)
│ └── components/
│ └── ui/ # ShadCN primitives
├── store/ # Redux — client state (Zone: store)
│ ├── slices/
│ └── hooks/
├── providers/ # React context providers
├── mocks/
├── test-utils/
└── constants/

ESLint zones:

ZoneDirectoryCan import from
bffsrc/server/shared
feature-*src/features/*/shared, store, other features (opt-in)
storesrc/store/shared
sharedsrc/shared/nothing (leaf)
pagessrc/app/ (non-api)features/*, shared, store, providers

Cross-feature imports can be controlled: by default, features cannot import from each other. When needed, the ESLint rule allows explicit exceptions:

// items feature can import from image-upload feature (for ImageFormField wiring)
{ from: 'feature-items', allow: ['feature-image-upload', 'shared', 'store'] }

Pros:

  • Feature code is colocated — all items logic in one directory
  • New features don’t touch shared directories
  • Cross-feature dependencies are explicit and auditable
  • Natural code-splitting boundaries

Cons:

  • Shared components (used by multiple features) need a decision: which feature owns them, or do they go in shared/components/?
  • BFF route handlers may serve multiple features (e.g., a single item route used by both items and kanban features)
  • Deep nesting: src/features/items/hooks/providers/useItemImageUpload.ts
  • Feature boundary decisions can be contentious (is “image upload” a feature or part of “items”?)

Option C: Hybrid — Layers with Feature Grouping

Section titled “Option C: Hybrid — Layers with Feature Grouping”

Top-level layers (like Option A) but with feature grouping within the component and hook layers.

src/
├── app/ # Next.js App Router
│ ├── api/ # Route files (thin, delegate to server/)
│ └── (pages)/ # Page routes
├── server/ # BFF-only (Zone: bff)
│ ├── routes/
│ ├── lib/
│ └── index.ts # import 'server-only'
├── api/ # SPA API functions (Zone: spa-api)
│ ├── items.ts
│ ├── image-upload.ts
│ └── kanban.ts
├── hooks/ # React hooks (Zone: hooks)
│ ├── items/ # Item-specific hooks
│ │ ├── queries.ts
│ │ ├── mutations.ts
│ │ └── providers.ts # FD-01 typed provider implementations
│ ├── image-upload/ # Image upload hooks
│ │ ├── mutations.ts
│ │ └── providers.ts
│ ├── cdn/
│ │ └── queries.ts
│ └── auth/
│ └── useAuth.ts
├── components/ # React components (Zone: ui)
│ ├── items/ # Feature-grouped
│ ├── scan/
│ ├── common/ # Cross-feature shared
│ └── ui/ # ShadCN primitives
├── store/ # Redux — client state (Zone: store)
│ ├── slices/
│ └── hooks/
├── shared/ # SPA + BFF shared code (Zone: shared)
│ ├── jwt-decode.ts
│ ├── types/
│ └── utils.ts
├── providers/ # React context providers
├── mocks/
├── test-utils/
└── constants/

ESLint zones — same as Option A, with additional granularity possible within hooks:

// hooks/items/ can import from api/items.ts but not api/kanban.ts
'boundaries/element-types': ['error', {
rules: [
{ from: 'hooks-items', allow: ['spa-api-items', 'store', 'shared'] },
{ from: 'hooks-image-upload', allow: ['spa-api-image-upload', 'shared'] },
],
}]

Pros:

  • Layers are clear at the top level (easy to understand the architecture)
  • Feature grouping within layers keeps related code close
  • ESLint zones are straightforward — top-level directories are zones
  • Less nesting than Option B
  • Natural migration path from current structure (move files into new top-level dirs)

Cons:

  • Feature code is still spread across directories (components/items/, hooks/items/, api/items.ts) — but they’re grouped within each layer
  • Per-feature ESLint rules within a zone require more configuration

CriterionOption A (Layers)Option B (Features)Option C (Hybrid)
ESLint zone clarityStrong — 1:1 with top dirsModerate — per-feature zones need enumerationStrong — 1:1 with top dirs
Boundary enforcementSimple — 7 zonesComplex — N features + shared zonesSimple — 7 zones, optional per-feature
Feature colocalityLow — items code in 4 dirsHigh — all in one dirMedium — grouped within layers
New feature effortTouch 4 directoriesCreate 1 directoryTouch 3 directories
Migration from currentModerate — restructure lib/, add server/, api/, shared/High — reorganize everythingModerate — same as A + feature dirs in hooks/components
Next.js compatibilityGood — thin re-exports in app/api/Good — sameGood — same
Cross-feature visibilityImplicit — hooks/ is flatExplicit — ESLint controlsImplicit within layer, explicit across
DiscoverabilityLayer → feature (top-down)Feature → layers (bottom-up)Layer → feature (top-down)

Regardless of the chosen directory structure, these rules should be adopted:

5.1 eslint-plugin-boundaries — Zone enforcement

Section titled “5.1 eslint-plugin-boundaries — Zone enforcement”
eslint.config.mjs
import boundaries from 'eslint-plugin-boundaries';
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'bff', pattern: 'src/server/**', mode: 'folder' },
{ type: 'spa-api', pattern: 'src/api/**', mode: 'folder' },
{ type: 'hooks', pattern: 'src/hooks/**', mode: 'folder' },
{ type: 'store', pattern: 'src/store/**', mode: 'folder' },
{ type: 'ui', pattern: 'src/components/**', mode: 'folder' },
{ type: 'providers', pattern: 'src/providers/**', mode: 'folder' },
{ type: 'shared', pattern: 'src/shared/**', mode: 'folder' },
{ type: 'pages', pattern: 'src/app/**', mode: 'folder',
capture: ['app'] },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'allow',
rules: [
// BFF cannot import SPA code
{ from: 'bff', disallow: ['ui', 'hooks', 'store', 'providers', 'spa-api'] },
// SPA API cannot import BFF or UI
{ from: 'spa-api', disallow: ['bff', 'ui', 'hooks', 'store', 'providers'] },
// UI cannot import BFF or SPA API directly
{ from: 'ui', disallow: ['bff', 'spa-api'] },
// Shared cannot import anything except types
{ from: 'shared', disallow: ['bff', 'spa-api', 'ui', 'hooks', 'store', 'providers'] },
// Store cannot import UI or BFF
{ from: 'store', disallow: ['bff', 'ui', 'providers'] },
],
}],
},
}

5.2 @typescript-eslint/no-restricted-imports — Package boundaries

Section titled “5.2 @typescript-eslint/no-restricted-imports — Package boundaries”
{
rules: {
'@typescript-eslint/no-restricted-imports': ['error', {
patterns: [
// Design system components must not import TanStack Query
// (enforced in ux-prototype, but also guard against leaks here)
{
group: ['@tanstack/react-query'],
importNamePattern: '^(useQuery|useMutation|useQueryClient)',
message: 'Do not import TanStack Query in components imported from design system. Use typed provider hooks instead (FD-01).',
},
// server-only modules must not be imported from client directories
{
group: ['@/server/*'],
message: 'Cannot import server-only code from this location. Use @/api/ for SPA-side API calls.',
},
],
}],
// Apply different rules to server files
},
// Override for BFF files — allow server imports
overrides: [{
files: ['src/server/**/*.ts', 'src/app/api/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': 'off', // server can import anything
},
}],
}
src/server/index.ts
import 'server-only';
// Build fails if any client component transitively imports this

The import rules form a directed acyclic graph:

PlantUML diagram


Option C (Hybrid) adopted for arda-frontend-app. Recorded as FD-02 in the project decision log.

Reasons:

  1. Matches the existing mental model — the current structure is already layer-based. Adding server/, api/, and shared/ as explicit layers (and restructuring lib/) is evolutionary, not revolutionary.

  2. ESLint zones are straightforward — top-level directories map 1:1 to boundary zones. No need to enumerate every feature as a zone.

  3. Feature grouping within layers provides colocality where it matters (hooks for items are in hooks/items/, not scattered) without the overhead of full vertical slices.

  4. Migration path is incremental — can be done in phases:

    • Phase 1: Create server/, api/, shared/; move lib/ contents (this is #734)
    • Phase 2: Add eslint-plugin-boundaries with zone rules
    • Phase 3: Group hooks and components by feature within their layers
    • Phase 4: Add per-feature ESLint rules within zones (optional)
  5. Compatible with TanStack Query adoptionhooks/queries/, hooks/mutations/, and hooks/providers/ (or grouped by feature: hooks/items/, hooks/image-upload/) have a clear home.


Copyright: (c) Arda Systems 2025-2026, All rights reserved