Overview
Organize code into feature modules with clear boundaries. Each feature has actions, services, repositories, and components with a public API via index.ts.
What it is
Domain-driven folder structure that encapsulates business logic by feature with clear layer separation.
Why we use it
Maintainable codebase, clear dependencies, easy refactoring, and consistent patterns across features.
When to use
Any new feature or domain concept. Create a feature folder in src/features/ with standard structure.
Key Features
- Standard feature folder structure
- Actions, services, repositories layers
- Public API via index.ts exports
- Consistent naming conventions
Quick Start
Feature Structure
Standard folder structure for features.
// Feature folder structure
src/features/items/
├── components/ # Feature-specific UI
│ └── item-card.tsx
├── actions.ts # Server Actions
├── services.ts # Business logic
├── repository.ts # Data access (Prisma)
├── schemas.ts # Zod validation
├── types.ts # TypeScript types
├── hooks.ts # React hooks
└── index.ts # Public API
// Usage from app layer
import { createItem, getItems } from '@/features/items';
const items = await getItems(userId);
const newItem = await createItem(data);Patterns
Data Access Layers
Repository, service, action separation.
// Data access layers (separation of concerns)
// 1. Repository - ONLY layer that imports Prisma
// src/features/items/repository.ts
import { prisma } from '@/lib/db';
export async function findItemById(id: string) {
return prisma.item.findUnique({ where: { id } });
}
export async function createItem(data: CreateItemInput) {
return prisma.item.create({ data });
}
// 2. Service - Business logic, calls repositories
// src/features/items/services.ts
import * as repository from './repository';
export async function createItemWithValidation(userId: string, data: CreateItemInput) {
// Business logic here
const exists = await repository.findByName(data.name);
if (exists) throw new ConflictError('Item already exists');
return repository.createItem({ ...data, userId });
}
// 3. Actions - Server Actions, calls services
// src/features/items/actions.ts
'use server';
import * as services from './services';
export async function createItemAction(data: FormData) {
const session = await auth();
return services.createItemWithValidation(session.user.id, parseFormData(data));
}Public API Export
Control what is exposed via index.ts.
// Public API via index.ts
// src/features/items/index.ts
// Only export what other modules should access
export { createItem, getItems, updateItem, deleteItem } from './services';
export { createItemAction } from './actions';
export { useItems, useItem } from './hooks';
export type { Item, CreateItemInput, UpdateItemInput } from './types';
// DO NOT export:
// - repository functions (internal)
// - internal helpers (internal)
// - private types (internal)Naming Conventions
Consistent naming for files and functions.
// Naming conventions
// Files: kebab-case
src/features/user-profile/
├── user-card.tsx
├── user-service.ts
└── index.ts
// Components: PascalCase
export function UserCard({ user }: UserCardProps) { ... }
// Functions: verb-noun (camelCase)
export function getUserById(id: string) { ... }
export function createUserProfile(data: ProfileInput) { ... }
export function updateUserSettings(userId: string, settings: Settings) { ... }
// Types: PascalCase
interface UserProfile { ... }
type CreateUserInput = { ... }Watch Out
Cross-feature internal imports
Don't
// Cross-feature imports - breaks encapsulation
// src/features/orders/services.ts
import { prisma } from '@/lib/db'; // Wrong - import Prisma directly
import { getUserEmail } from '@/features/users/repository'; // Wrong - access internal
export async function createOrder(userId: string) {
const email = await getUserEmail(userId); // Breaks isolation
// ...
}Do
// Proper encapsulation
// src/features/orders/services.ts
import { getUser } from '@/features/users'; // Import from public API
import * as repository from './repository';
export async function createOrder(userId: string) {
const user = await getUser(userId); // Uses public API
const order = await repository.createOrder({ userId });
await sendOrderConfirmation(user.email, order);
return order;
}Missing index.ts exports
Don't
// Missing index exports
// Other modules importing internal files directly
// src/app/api/items/route.ts
import { findItemById } from '@/features/items/repository'; // Wrong!
import { ItemSchema } from '@/features/items/schemas'; // Wrong!Do
// Proper usage via index.ts
// src/app/api/items/route.ts
import { getItem, ItemSchema } from '@/features/items'; // Correct!
// Feature controls what is exposed
// Internal implementation can change without breaking consumers- Inconsistent folder structure
- Importing Prisma outside repository