A case study on how we organised 17 feature modules into a predictable, maintainable, five-layer pattern - and why it worked.
1. The Problem
Every non-trivial web application starts simple. You have a few screens, a handful of API calls, and the folder structure just feels right. Then the business asks for more features. And more. And more.
Six months in, you have:
- API calls scattered across component files
- Types defined inline or not at all
- Three different ways of fetching data (one per developer)
- Forms that blur validation, submission, and rendering logic
- A
components/directory with 200 files and no meaningful grouping - PRs where it's impossible to tell what touches what
We hit this wall on a Next.js finance platform. The application handles loan origination, disbursement, collections, accounting, reporting, CRM, HRMS, and a dozen other domains. Without structure, this was unsustainable.
This is the pattern we built to survive.
2. The Pattern at a Glance
Every discrete domain in the application - every "feature" - gets its own
directory under src/features/<feature-name>/. Inside that directory, the
code is organised into five layers, each with a single responsibility and a
strict dependency direction:
src/features/<name>/
├── types.ts # 1. Data shapes
├── api.ts # 2. HTTP calls
├── queries.ts # 3. Query key definitions
├── hooks.ts # 4. React hooks (queries + mutations)
├── schema.ts # 5. Validation schemas (optional)
└── components/ # 6. UI components
The dependency flow is one-way:
types.ts → api.ts → queries.ts → hooks.ts → components/
pages/
Each layer imports only from layers below it (or from shared libraries).
Components never import from api.ts directly. Pages never write their own
data fetching logic.
3. Layer 1: Types (types.ts)
This is the foundation. Every entity the feature works with - API responses, request bodies, computed display models - gets an explicit interface.
// types.ts
export interface IProduct {
id: string
name: string
categoryId: string
price: number
status: "ACTIVE" | "INACTIVE" | "DISCONTINUED"
createdAt: string
}
export interface IProductBody {
name: string
categoryId: string
price: number
status: "ACTIVE" | "INACTIVE"
}
export interface IProductCategory {
id: string
name: string
parentId: string | null
}
Key rules:
- Response types vs body types - What the API returns (
IProduct) is often different from what it expects (IProductBody). Keeping them separate prevents coupling between the API shape and the form shape. - Colocation - Types live with the feature that owns them. If two features
share a type, it goes into
src/types/with a clear naming prefix.
4. Layer 2: API (api.ts)
This layer is a thin wrapper around our HTTP client (Axios). Every function takes typed parameters, calls one endpoint, and returns the raw response.
// api.ts
import { makeRequest } from "@/lib/http"
import type { AxiosRequestConfig } from "axios"
import type { IProduct, IProductBody } from "./types"
interface IResponseType<T> {
data: T
message?: string
}
export interface IGetProductFilters {
id: string
categoryId: string
status: "ACTIVE" | "INACTIVE" | "DISCONTINUED"
}
export function getProducts(
options?: Partial<IGetProductFilters>,
axiosRequestConfig?: AxiosRequestConfig,
) {
return makeRequest.get<IResponseType<IProduct[]>>("/catalog/products", {
...axiosRequestConfig,
params: {
...axiosRequestConfig?.params,
...options,
},
})
}
export function getProductById(id: string) {
return makeRequest.get<IResponseType<IProduct>>(`/catalog/products/${id}`)
}
export function createProduct(body: IProductBody) {
return makeRequest.post<IResponseType<IProduct>>("/catalog/products", body)
}
export function updateProduct(id: string, body: Partial<IProductBody>) {
return makeRequest.put<IResponseType<IProduct>>(`/catalog/products/${id}`, body)
}
export function deleteProduct(id: string) {
return makeRequest.delete(`/catalog/products/${id}`)
}
Why this layer exists:
- Testability - API functions can be mocked at the boundary without mocking Axios or intercepting network calls.
- Centralised configuration - Every request goes through the same Axios instance with interceptors for auth tokens, error normalisation, and serialisation.
- Forwarding request config - Accepting
AxiosRequestConfiglets callers override headers, cancel tokens, or timeout on individual calls.
5. Layer 3: Queries (queries.ts)
This is the bridge between raw API functions and React's data lifecycle. We use a query key factory to define every server state our feature cares about.
// queries.ts
import { createQueryKeys } from "@lukemorales/query-key-factory"
import { getProducts, getProductById, type IGetProductFilters } from "./api"
export const productQueries = createQueryKeys("products", {
list: (filters?: Partial<IGetProductFilters>) => ({
queryKey: [filters],
queryFn: async () => {
const res = await getProducts(filters)
return res.data.data
},
}),
detail: (id: string) => ({
queryKey: [id],
queryFn: async () => {
const res = await getProductById(id)
return res.data.data
},
}),
})
Why queries are separate from hooks:
- Reusability - Query definitions can be composed, extended, or invalidated without needing a component context. They are plain objects.
- Type safety in query keys - The factory generates typed keys. You cannot accidentally invalidate the wrong query because the key shape is enforced.
- One place for data transformation - The
queryFnis where we unwrap the response envelope (res.data.data). Components never see the HTTP response structure.
6. Layer 4: Hooks (hooks.ts)
This is the public API of the feature module. Hooks are what components and
pages import. They wrap TanStack Query's useQuery and useMutation with
feature-specific defaults.
// hooks.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { createProduct, updateProduct, deleteProduct } from "./api"
import { productQueries } from "./queries"
import type { IProductBody } from "./types"
interface IUseProductListOptions {
enabled?: boolean
filters?: Partial<Parameters<typeof productQueries.list>[0]>
}
export function useGetProductListQuery({
enabled = true,
filters,
}: IUseProductListOptions = {}) {
return useQuery({
enabled,
...productQueries.list(filters),
})
}
export function useGetProductDetailQuery(id: string) {
return useQuery({
enabled: !!id,
...productQueries.detail(id),
})
}
export function useCreateProductMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (body: IProductBody) => {
const res = await createProduct(body)
return res.data
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: productQueries.list().queryKey,
})
},
})
}
export function useDeleteProductMutation(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await deleteProduct(id)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: productQueries.list().queryKey,
})
},
})
}
Conventions:
- Query hooks are prefixed
useGet+ noun +Query-useGetProductListQuery,useGetProductDetailQuery. This reads as "use the get-product-list query" and distinguishes them from mutations at a glance. - Mutation hooks are prefixed
use+ verb + noun +Mutation-useCreateProductMutation,useDeleteProductMutation. - First argument is always an options object with
enabledandfilters- This provides a consistent API. Defaultenabledistrue. - Mutations invalidate on success - The mutation hook owns the invalidation logic. Components that call the mutation don't need to know what to refetch.
- No
useQueryClient()in components - It's encapsulated in the hook.
Composing multiple APIs in one hook
Some screens need data from several endpoints before they can render. The hook layer handles this trivially - just compose multiple queries:
// hooks.ts
import { useQuery } from "@tanstack/react-query"
import { productQueries } from "./queries"
import { categoryQueries } from "../categories/queries"
interface IUseProductFormInitialData {
productId: string
}
export function useProductFormInitialData({
productId,
}: IUseProductFormInitialData) {
const product = useQuery({
enabled: !!productId,
...productQueries.detail(productId),
})
const categories = useQuery({
...categoryQueries.list(),
})
return {
product: product.data,
categories: categories.data,
isLoading: product.isLoading || categories.isLoading,
isError: product.isError || categories.isError,
}
}
The component calling useProductFormInitialData gets a single return value
and doesn't need to know about the underlying API boundaries. This pattern
also makes it easy to add preloading, caching, or dependent queries without
touching the component.
7. Layer 5: Schema (schema.ts) - Optional
For features with forms, we define validation schemas here instead of inlining them in components.
// schema.ts
import { z } from "zod"
export const productFormSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
categoryId: z.string().trim().min(1, "Category is required"),
price: z.number().min(0, "Price must be positive"),
status: z.enum(["ACTIVE", "INACTIVE"]),
})
export type ProductFormValues = z.infer<typeof productFormSchema>
Why optional: Simple features without forms don't need a schema.ts.
We add it only when forms appear. The schema module also exports the inferred
TypeScript type, avoiding duplication between the schema and the interface.
8. Layer 6: Components (components/)
UI components live in the feature directory, organised by purpose. There is no strict sub-directory convention - 2-5 flat files per feature is typical.
// components/product-table.tsx
"use client"
import { useReactTable, getCoreRowModel, ColumnDef } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { useGetProductListQuery } from "../hooks"
import type { IProduct } from "../types"
const columns: ColumnDef<IProduct>[] = [
{
id: "name",
header: "Product",
accessorKey: "name",
},
{
id: "price",
header: "Price",
accessorKey: "price",
cell: ({ getValue }) => `$${getValue<number>().toFixed(2)}`,
},
{
id: "status",
header: "Status",
accessorKey: "status",
cell: ({ getValue }) => (
<Badge variant={getValue() === "ACTIVE" ? "success" : "muted"}>
{getValue<string>()}
</Badge>
),
},
]
export function ProductTable() {
const { data: products } = useGetProductListQuery()
const table = useReactTable({
data: products ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
Component rules:
- Components import only from
../hooksand../types- never from../api.tsor../queries.ts. - Data fetching hooks are called at the top level - no
useEffect+setStatepatterns for server data. - No prop drilling deeper than 2 levels - use composition (children, render props) or context.
9. Variations on the Pattern
Not every feature fits the exact same mold. Here are the variations we encountered and how we handled them.
Nested sub-features
Some domains are too large for a single flat directory. Our configurations module has 15 sub-entities (branches, loan products, vendors, dealers, etc.), each deserving its own feature module:
src/features/configurations/
├── branches/
│ ├── api.ts
│ ├── hooks.ts
│ ├── queries.ts
│ ├── types.ts
│ ├── schema.ts
│ └── components/
├── loan-products/
│ ├── api.ts
│ ├── hooks.ts
│ ├── queries.ts
│ ├── types.ts
│ └── schema.ts
├── vendors/
│ └── ... (same pattern)
└── ...
The parent configurations/ directory is just a namespace. It has no
index.ts, no shared types - each child is self-contained.
Complex features with sub-domains
The loan application feature is the most complex in the system. It has tabs, receipts, document management, financial calculations, and legal workflows. Its directory is deeper:
src/features/loan-application/
├── index.ts # Public API (re-exports)
├── types.ts # Shared types
├── types/ # Sub-domain type files
├── components/ # 27+ components
├── tabs/ # Tab-specific components
├── utils/ # Feature-specific utilities
├── hooks/ # More hooks than a single file can hold
├── api.ts
├── queries.ts
└── ...
The core five-layer skeleton is still there - types, api, queries,
hooks - but the components directory grows richer and sub-directories
emerge for organisation.
Barrel exports (index.ts)
We generally avoid barrel exports - they can increase bundle size by
preventing tree-shaking and creating circular dependency risks. Components
should import directly from the file they need (import { useXQuery } from "../hooks").
That said, barrel exports can still be useful in specific cases:
- When a feature's public API needs to be consumed across multiple other features (e.g., shared types or hooks)
- When the feature is a library-style module (e.g., a utility or dropdown data provider)
If you add one, keep it shallow - re-export only what is needed externally, not every symbol in the module.
10. Why It Worked - A Retrospective
What it solved
1. Mental model uniformity. Every developer knows exactly where to put a new API call, a new type, or a new component. There is no deliberation - just follow the pattern. This dramatically reduced decision fatigue and onboarding time.
2. PR clarity. A pull request that touches types.ts, api.ts, and
hooks.ts in the same feature is clearly adding an endpoint. One that only
touches components/ is a UI change. Reviewers can tell the scope of a
change from the file list alone.
3. Fearless refactoring. Want to rename a field across the entire stack?
The one-way dependency chain means you start at types.ts and follow the
errors. No runtime surprises. Want to swap the data fetching library? Only
queries.ts and hooks.ts change - api.ts stays, components/ stays.
4. Separation of concerns without abstraction overkill. There is no
BaseApiService or AbstractRepositoryFactory. The pattern has layers but
no inheritance, no generics magic, no decorators. Each file is a plain
collection of functions or components. Junior developers understand it in
an hour.
5. Tree-shaking and code-splitting. Feature directories can be independently loaded. Next.js route segments can lazy-load entire feature bundles because nothing is coupled through a monolithic store or service.
6. Scales across projects, not just within one. This pattern isn't tied to a single codebase. The same structure has been applied to five other projects - a CRM, an HRMS, a fleet management dashboard, an e-commerce admin panel, and a healthcare records system - with minimal adaptation. In every case, the team adopted it within days and reported fewer cross-module bugs. The pattern's portability comes from its simplicity: it's just files in folders with one-way dependencies. No framework, no package, no plugin to maintain.
What it cost
1. Boilerplate. Adding a new feature means creating 5 files before writing any real logic. For very simple features (a dropdown that calls one endpoint), this feels heavy.
2. File count. The project has hundreds of small files. Some developers prefer fewer, larger files. The tradeoff is navigation ease vs. tab overload.
3. Occasional boundary friction. Sometimes a component needs data from two features. Do you create a cross-feature hook? A shared parent component? A page-level orchestrator? The pattern doesn't prescribe the answer - you have to reason about it each time.
11. When to Use This Pattern
This pattern shines when:
- The application has 10+ distinct business domains (finance, healthcare, logistics, enterprise SaaS)
- You have multiple teams or contributors who need clear boundaries
- Your backend is REST-ish with standardised response envelopes
- Forms and tables are the primary UI pattern (admin panels, dashboards, back-office tools)
- You expect the app to live for years and need to manage technical debt
It's probably overkill for:
- A marketing site or content-heavy app
- An app with fewer than 5 data entities
- A prototype or MVP where speed of iteration trumps structure
12. In Practice: A Day in the Life
When a ticket says "Add a discount field to the product form," the workflow is:
types.ts- Adddiscount?: numbertoIProductBodyschema.ts- Adddiscount: z.number().min(0).max(100).optional()to the validationapi.ts- Nothing changes (the endpoint already accepts the field)components/product-form.tsx- Add a new form field bound to thediscountproperty- Verify -
npm run lint,npm run typecheck, visual test
That's it. No hunting for where the API call is made. No wondering if other components need updating. The change is fully contained in the feature module.
When the ticket says "Add a related products endpoint," the workflow is:
types.ts- AddIRelatedProductinterfaceapi.ts- AddgetRelatedProducts()functionqueries.ts- AddrelatedProducts: (id) => ({ queryKey, queryFn })hooks.ts- AdduseGetRelatedProductsQuery(id)components/related-products.tsx- Build the UI using the new hook- Wire it in a page - The page imports only the component and places it in the layout
13. Closing Thoughts
The best architecture is the one your team can reason about without a whiteboard. This pattern isn't clever - it's boring. And that's exactly why it worked.
We didn't invent anything new. We took the well-established ideas of separation of concerns, colocation, and unidirectional data flow and applied them with a consistent file naming convention. The result is a codebase where you can open any feature directory and immediately understand how it works.
Three tips if you adopt this:
- Enforce the pattern in code review, not by tooling. A linter can't tell if your import is philosophically correct. Teach the team why the layers matter.
- Allow exceptions, but document them. We have a few features that deviate - and each one has a comment explaining why.
- Keep the standard response type (
IResponseType<T>) in shared types. If every API layer unwraps the envelope differently, the pattern breaks.