CentercodePorygon
DittoPorygonAPI

Feature Structure

Domain-driven feature module organization

Reliable

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

Related

Prisma ORM

Database access

Scope Model

Scope isolation

Validation

Input validation