Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,23 @@ paths:
└── src/
├── app/ # App-level configuration (App.tsx, Providers.tsx)
├── features/ # Feature modules using feature slice architecture
│ ├── auth/ # Authentication feature
│ ├── auth/ # Authentication feature (cross-cutting concern)
│ ├── carts/ # Shopping cart feature
│ ├── products/ # Product catalog feature
│ └── marketing/ # Feature with sub-feature slices
│ ├── components/ # Marketing-wide components
│ ├── providers/ # Marketing-wide providers
│ ├── models/ # Marketing-wide models
│ ├── rating/ # Sub-feature slice
│ │ ├── components/
│ │ ├── application/
│ │ ├── providers/
│ │ └── models/
│ └── reviews/ # Sub-feature slice
│ ├── components/
│ ├── application/
│ ├── providers/
│ └── models/
├── lib/ # Shared libraries and utilities
│ ├── api/ # Centralized API layer (queries, mutations, DTOs)
│ ├── components/ # Reusable UI components
Expand All @@ -44,10 +58,6 @@ paths:

## Feature Architecture

Each feature follows feature slice architecture patterns with three layers:

## Feature Architecture

Each feature follows feature slice architecture patterns with four layers:

- **components/** - UI components, presentational and decoupled from business logic (application) and router state. Data access is only through `providers/`.
Expand All @@ -56,7 +66,7 @@ Each feature follows feature slice architecture patterns with four layers:
- files are named after their primary export or reexport: `useCartProductsQuery` → `use-cart-products-query.ts`, `useAddToCartMutation` → `use-add-to-cart-mutation.ts`
- **models/** - Domain type definitions only. Exposes frontend models for the feature. When the DTO shape is identical to the domain model, re-export with a domain name (`export type { ProductDto as Product }`). When it diverges, define the domain type here.

**Dependency rule:**
### Dependency rule

| Layer | May import from |
| -------------- | ------------------------------------------------ |
Expand All @@ -67,6 +77,14 @@ Each feature follows feature slice architecture patterns with four layers:

**Cross-slice primitives:** `features/auth/` and `features/authv2/` are cross-cutting concerns (identity, permissions, auth state). Any feature slice may import from them.

### Sub-feature Slices

When a feature grows to contain multiple distinct domain sub-areas, each sub-area becomes a **sub-feature slice** — a nested directory with its own four-layer structure (`components/`, `application/`, `providers/`, `models/`).

The parent feature's layers hold code that is either reusable across sub-feature slices, or too small to warrant its own sub-feature slice.

The same layer dependency rules apply within sub-feature slices. Additionally, sub-feature layers may import from the parent feature's same or lower layers. Sub-feature slices **may not import from sibling sub-feature slices**.

## API Library

`src/lib/api/` is the global home for all HTTP logic: `queryOptions` factories, loaders, mutation hooks, query keys, domain errors, and DTOs, organised by resource. Query files expose `queryOptions` factories (no `useQuery` hooks — hook composition belongs in `providers/`). Feature `providers/` compose hooks on top of those factories and re-export them for feature slice. New API logic always goes in `src/lib/api/` first, then gets exposed through the relevant feature's `providers/`.
Expand Down
154 changes: 3 additions & 151 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,79 +10,7 @@ import vitest from "eslint-plugin-vitest";
import reactRefresh from "eslint-plugin-react-refresh";
import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";

// used by import/no-restricted-paths
// AIDEV-NOTE: import/no-restricted-paths uses path.relative() for matching — glob wildcards
// in target/from are NOT supported. All zone lists must be generated per-feature.
const featureSlices = ["carts", "marketing", "products"];
const allFeatureSlices = [
"auth",
"authv2",
"carts",
"demo",
"marketing",
"products",
];

// AIDEV-NOTE: `auth` and `authv2` are cross-slice primitives (identity, permissions,
// auth state). Any feature may import from them. See docs/architecture.md
const featureToFeatureZones = featureSlices.map((feature) => ({
target: `./src/features/${feature}`,
from: "./src/features",
except: [`./${feature}`, "./auth", "./authv2"],
message: "Avoid importing from other features.",
}));

const featureLayerZones = allFeatureSlices.flatMap((feature) => [
// application/ ← components/ (forbidden)
{
target: `./src/features/${feature}/application`,
from: `./src/features/${feature}/components`,
message: "application/ must not depend on components/.",
},
// providers/ ← application/ or components/ (forbidden)
{
target: `./src/features/${feature}/providers`,
from: `./src/features/${feature}/application`,
message: "providers/ must not depend on application/.",
},
{
target: `./src/features/${feature}/providers`,
from: `./src/features/${feature}/components`,
message: "providers/ must not depend on components/.",
},
// models/ ← any feature layer (forbidden)
{
target: `./src/features/${feature}/models`,
from: `./src/features/${feature}/application`,
message: "models/ must not depend on application/.",
},
{
target: `./src/features/${feature}/models`,
from: `./src/features/${feature}/components`,
message: "models/ must not depend on components/.",
},
{
target: `./src/features/${feature}/models`,
from: `./src/features/${feature}/providers`,
message: "models/ must not depend on providers/.",
},
]);

// Prevents lib/api/ from leaking beyond providers/ and models/.
const apiLayerIsolationZones = allFeatureSlices.flatMap((feature) => [
{
target: `./src/features/${feature}/components`,
from: "./src/lib/api",
message:
"src/lib/api/ must not be imported in components/. Use providers/ for data queries and mutations and models/ for types.",
},
{
target: `./src/features/${feature}/application`,
from: "./src/lib/api",
message:
"src/lib/api/ must not be imported in application/. Use providers/ for data queries and mutations and models/ for types.",
},
]);
import { featureSliceConfig } from "./eslint.feature-slices.mjs";

const noAnonymousUseEffectRule = [
"error",
Expand All @@ -105,19 +33,6 @@ const baseNoRestrictedImports = {
],
};

const reactQueryHooksRestriction = {
name: "@tanstack/react-query",
importNames: [
"useQuery",
"useMutation",
"useSuspenseQuery",
"useQueries",
"useSuspenseQueries",
"useQueryClient",
],
message: "React Query hooks belong in providers/, not here.",
};

const noTestsDirectoryRule = [
"error",
{
Expand Down Expand Up @@ -296,73 +211,10 @@ export default defineConfig(
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-spread": "off",
"@typescript-eslint/no-unsafe-return": "off",
},
},
{
files: ["./src/features/**"],
rules: {
"import/no-restricted-paths": [
"error",
{
zones: [
...featureToFeatureZones,
...featureLayerZones,
...apiLayerIsolationZones,
],
},
],
},
},
{
files: [
"./src/features/*/components/**",
"./src/features/*/application/**",
],
rules: {
"no-restricted-imports": [
"error",
{
...baseNoRestrictedImports,
paths: [...baseNoRestrictedImports.paths, reactQueryHooksRestriction],
},
],
},
},
{
files: ["./src/pages/**"],
rules: {
"import/no-restricted-paths": [
"error",
{
zones: [
{
target: "./src/pages",
from: "./src/lib/api",
message:
"src/lib/api/ must not be imported in pages/. Use features/*/providers/ for data and features/*/models/ for types.",
},
],
},
],
},
},
{
files: ["./src/lib/**"],
rules: {
"import/no-restricted-paths": [
"error",
{
zones: [
{
target: "./src/lib",
from: "./src/features",
message: "Lib should not depend on features.",
},
],
},
],
},
},
...featureSliceConfig({ baseNoRestrictedImports }),
{
files: ["src/**/__tests__/**"],
rules: {
Expand Down
Loading
Loading