home / skills / gilbertopsantosjr / fullstacknextjs / gs-create-domain-module

gs-create-domain-module skill

/skills/gs-create-domain-module

This skill helps generate production-ready feature modules using Clean Architecture principles for Next.js 15+, DynamoDB, and Vitest.

npx playbooks add skill gilbertopsantosjr/fullstacknextjs --skill gs-create-domain-module

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
31.6 KB
---
description: Creates complete, production-ready feature modules following Clean Architecture OOP principles with Entities, Use Cases, Repository pattern, DI Container, and thin adapter actions. Adapted for Next.js 15+, DynamoDB/OneTable, ZSA, and Vitest.
name: gs-create-domain-module
---

# Feature Module Generator (Clean Architecture OOP)

You are an expert in creating feature modules that comply with Clean Architecture principles, emphasizing the Dependency Rule, Entity-centric design, Use Case classes, Repository pattern, and Dependency Injection.

## Technology Stack

| Component | Technology |
|-----------|------------|
| Framework | Next.js 15+ / React 19+ |
| Database | DynamoDB (single-table design) |
| ORM | OneTable |
| Server Actions | ZSA (Zod Server Actions) |
| Validation | Zod (input shape) + Entity.validate() (business rules) |
| Testing | Vitest |
| ID Generation | ULID |
| Deployment | SST (Serverless Stack) |
| DI Container | Custom lightweight container |

## Core Principles Applied

| Principle | How Applied |
|-----------|-------------|
| **Dependency Rule** | Domain → Application → Infrastructure → Presentation (inward only) |
| **Entity-Centric** | Rich domain objects with behavior, private constructor, factory methods |
| **Use Case Classes** | Single-responsibility classes with `execute()` method |
| **Repository Pattern** | Interface in Domain, implementation in Infrastructure |
| **Dependency Injection** | Constructor injection, resolved via DI Container |
| **Thin Adapters** | Server actions are 3-5 lines, only orchestration |

## When to Use This Skill

Use this skill when:

- Creating a new feature module from scratch
- User asks to "create a feature", "scaffold a feature", or "generate a module"
- User mentions needing a new business domain (categories, accounts, billing, etc.)
- Starting a new bounded context that needs its own entities and logic

## Requirements Gathering

Before generating any code, ask the user these questions:

1. **Feature name** (kebab-case, e.g., "category", "account", "notification")
2. **Initial entities** (comma-separated list, e.g., "Category, Subcategory")
3. **Key business rules** (what validations belong in the Entity?)
4. **Key access patterns** (how will data be queried? e.g., "by userId", "by status")
5. **Cross-feature dependencies** (does this feature depend on others?)

## Folder Structure Generation

Generate this structure for feature modules:

```
src/
├── backend/
│   ├── domain/
│   │   └── <feature>/
│   │       ├── entities/
│   │       │   ├── <Entity>.ts
│   │       │   └── index.ts
│   │       ├── repositories/
│   │       │   ├── I<Entity>Repository.ts
│   │       │   └── index.ts
│   │       ├── value-objects/
│   │       │   └── index.ts
│   │       ├── exceptions/
│   │       │   ├── <Feature>Exceptions.ts
│   │       │   └── index.ts
│   │       └── index.ts
│   │
│   ├── application/
│   │   └── <feature>/
│   │       ├── use-cases/
│   │       │   ├── Create<Entity>UseCase.ts
│   │       │   ├── Update<Entity>UseCase.ts
│   │       │   ├── Delete<Entity>UseCase.ts
│   │       │   ├── Get<Entity>UseCase.ts
│   │       │   ├── List<Entity>sUseCase.ts
│   │       │   └── index.ts
│   │       ├── dtos/
│   │       │   ├── <Entity>DTO.ts
│   │       │   └── index.ts
│   │       └── index.ts
│   │
│   └── infrastructure/
│       └── <feature>/
│           ├── repositories/
│           │   ├── DynamoDB<Entity>Repository.ts
│           │   └── index.ts
│           └── index.ts
│
├── features/
│   └── <feature>/
│       ├── actions/
│       │   ├── create-<entity>-action.ts
│       │   ├── update-<entity>-action.ts
│       │   ├── delete-<entity>-action.ts
│       │   ├── get-<entity>-action.ts
│       │   ├── list-<entity>s-action.ts
│       │   └── index.ts
│       ├── schemas/
│       │   ├── <entity>-schemas.ts
│       │   └── index.ts
│       ├── components/
│       │   └── index.ts
│       └── index.ts
│
└── test/
    └── <feature>/
        ├── entities/
        │   └── <Entity>.test.ts
        ├── use-cases/
        │   └── Create<Entity>UseCase.test.ts
        └── repositories/
            └── DynamoDB<Entity>Repository.test.ts
```

## Component Generation Instructions

### 1. Entity Class (Domain Layer)

```typescript
// src/backend/domain/<feature>/entities/<Entity>.ts
import { ulid } from 'ulid'

export interface <Entity>Props {
  id: string
  userId: string
  name: string
  description?: string
  status: <Entity>Status
  createdAt: Date
  updatedAt: Date
}

export type <Entity>Status = 'active' | 'inactive' | 'archived'

export interface Create<Entity>Input {
  name: string
  description?: string
}

export class <Entity> {
  private constructor(private readonly props: <Entity>Props) {
    this.validate()
  }

  // Factory method for creating new entities
  static create(input: Create<Entity>Input & { userId: string }): <Entity> {
    const now = new Date()
    return new <Entity>({
      id: ulid(),
      userId: input.userId,
      name: input.name,
      description: input.description,
      status: 'active',
      createdAt: now,
      updatedAt: now,
    })
  }

  // Factory method for reconstituting from persistence
  static fromPersistence(data: Record<string, unknown>): <Entity> {
    return new <Entity>({
      id: data.id as string,
      userId: data.userId as string,
      name: data.name as string,
      description: data.description as string | undefined,
      status: data.status as <Entity>Status,
      createdAt: new Date(data.createdAt as string),
      updatedAt: new Date(data.updatedAt as string),
    })
  }

  // Business rule validation (throws DomainException on failure)
  private validate(): void {
    if (!this.props.name || this.props.name.trim().length === 0) {
      throw new <Entity>ValidationException('Name is required')
    }
    if (this.props.name.length > 255) {
      throw new <Entity>ValidationException('Name must be 255 characters or less')
    }
    // Add more business rules here
  }

  // Getters for read-only access
  get id(): string { return this.props.id }
  get userId(): string { return this.props.userId }
  get name(): string { return this.props.name }
  get description(): string | undefined { return this.props.description }
  get status(): <Entity>Status { return this.props.status }
  get createdAt(): Date { return this.props.createdAt }
  get updatedAt(): Date { return this.props.updatedAt }

  // Domain methods with behavior
  updateName(name: string): <Entity> {
    return new <Entity>({
      ...this.props,
      name,
      updatedAt: new Date(),
    })
  }

  archive(): <Entity> {
    if (this.props.status === 'archived') {
      throw new <Entity>AlreadyArchivedException(this.props.id)
    }
    return new <Entity>({
      ...this.props,
      status: 'archived',
      updatedAt: new Date(),
    })
  }

  // Conversion to persistence format
  toPersistence(): Record<string, unknown> {
    return {
      id: this.props.id,
      userId: this.props.userId,
      name: this.props.name,
      description: this.props.description,
      status: this.props.status,
      createdAt: this.props.createdAt.toISOString(),
      updatedAt: this.props.updatedAt.toISOString(),
    }
  }
}
```

### 2. Domain Exceptions

```typescript
// src/backend/domain/<feature>/exceptions/<Feature>Exceptions.ts
import { DomainException } from '@/backend/shared/exceptions'

export class <Entity>ValidationException extends DomainException {
  constructor(message: string) {
    super(message, '<ENTITY>_VALIDATION_ERROR')
  }
}

export class <Entity>NotFoundException extends DomainException {
  constructor(id: string) {
    super(`<Entity> with id ${id} not found`, '<ENTITY>_NOT_FOUND')
  }
}

export class <Entity>AlreadyArchivedException extends DomainException {
  constructor(id: string) {
    super(`<Entity> with id ${id} is already archived`, '<ENTITY>_ALREADY_ARCHIVED')
  }
}

export class <Entity>UnauthorizedException extends DomainException {
  constructor(id: string, userId: string) {
    super(`User ${userId} is not authorized to access <Entity> ${id}`, '<ENTITY>_UNAUTHORIZED')
  }
}
```

### 3. Repository Interface (Domain Layer)

```typescript
// src/backend/domain/<feature>/repositories/I<Entity>Repository.ts
import type { <Entity> } from '../entities/<Entity>'

export interface I<Entity>Repository {
  save(entity: <Entity>): Promise<void>
  findById(id: string, userId: string): Promise<<Entity> | null>
  findByUserId(userId: string, options?: ListOptions): Promise<PaginatedResult<<Entity>>>
  delete(id: string, userId: string): Promise<void>
}

export interface ListOptions {
  limit?: number
  cursor?: string
  status?: string
}

export interface PaginatedResult<T> {
  items: T[]
  nextCursor?: string
  hasMore: boolean
}
```

### 4. DTO (Application Layer)

```typescript
// src/backend/application/<feature>/dtos/<Entity>DTO.ts
import type { <Entity> } from '@/backend/domain/<feature>/entities/<Entity>'

export interface <Entity>DTO {
  id: string
  userId: string
  name: string
  description: string | null
  status: string
  createdAt: string
  updatedAt: string
}

export class <Entity>DTOMapper {
  static fromEntity(entity: <Entity>): <Entity>DTO {
    return {
      id: entity.id,
      userId: entity.userId,
      name: entity.name,
      description: entity.description ?? null,
      status: entity.status,
      createdAt: entity.createdAt.toISOString(),
      updatedAt: entity.updatedAt.toISOString(),
    }
  }

  static fromEntities(entities: <Entity>[]): <Entity>DTO[] {
    return entities.map(this.fromEntity)
  }
}
```

### 5. Use Case Classes (Application Layer)

```typescript
// src/backend/application/<feature>/use-cases/Create<Entity>UseCase.ts
import { <Entity> } from '@/backend/domain/<feature>/entities/<Entity>'
import type { I<Entity>Repository } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { <Entity>DTO, <Entity>DTOMapper } from '../dtos/<Entity>DTO'

export interface Create<Entity>Input {
  name: string
  description?: string
  userId: string
}

export class Create<Entity>UseCase {
  constructor(private readonly <entity>Repository: I<Entity>Repository) {}

  async execute(input: Create<Entity>Input): Promise<<Entity>DTO> {
    const entity = <Entity>.create({
      name: input.name,
      description: input.description,
      userId: input.userId,
    })

    await this.<entity>Repository.save(entity)

    return <Entity>DTOMapper.fromEntity(entity)
  }
}
```

```typescript
// src/backend/application/<feature>/use-cases/Get<Entity>UseCase.ts
import type { I<Entity>Repository } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { <Entity>NotFoundException, <Entity>UnauthorizedException } from '@/backend/domain/<feature>/exceptions'
import { <Entity>DTO, <Entity>DTOMapper } from '../dtos/<Entity>DTO'

export interface Get<Entity>Input {
  id: string
  userId: string
}

export class Get<Entity>UseCase {
  constructor(private readonly <entity>Repository: I<Entity>Repository) {}

  async execute(input: Get<Entity>Input): Promise<<Entity>DTO> {
    const entity = await this.<entity>Repository.findById(input.id, input.userId)

    if (!entity) {
      throw new <Entity>NotFoundException(input.id)
    }

    if (entity.userId !== input.userId) {
      throw new <Entity>UnauthorizedException(input.id, input.userId)
    }

    return <Entity>DTOMapper.fromEntity(entity)
  }
}
```

```typescript
// src/backend/application/<feature>/use-cases/List<Entity>sUseCase.ts
import type { I<Entity>Repository, ListOptions } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { <Entity>DTOMapper, type <Entity>DTO } from '../dtos/<Entity>DTO'

export interface List<Entity>sInput {
  userId: string
  limit?: number
  cursor?: string
  status?: string
}

export interface List<Entity>sOutput {
  items: <Entity>DTO[]
  nextCursor?: string
  hasMore: boolean
}

export class List<Entity>sUseCase {
  constructor(private readonly <entity>Repository: I<Entity>Repository) {}

  async execute(input: List<Entity>sInput): Promise<List<Entity>sOutput> {
    const options: ListOptions = {
      limit: input.limit ?? 20,
      cursor: input.cursor,
      status: input.status,
    }

    const result = await this.<entity>Repository.findByUserId(input.userId, options)

    return {
      items: <Entity>DTOMapper.fromEntities(result.items),
      nextCursor: result.nextCursor,
      hasMore: result.hasMore,
    }
  }
}
```

```typescript
// src/backend/application/<feature>/use-cases/Update<Entity>UseCase.ts
import type { I<Entity>Repository } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { <Entity>NotFoundException, <Entity>UnauthorizedException } from '@/backend/domain/<feature>/exceptions'
import { <Entity>DTO, <Entity>DTOMapper } from '../dtos/<Entity>DTO'

export interface Update<Entity>Input {
  id: string
  userId: string
  name?: string
  description?: string
}

export class Update<Entity>UseCase {
  constructor(private readonly <entity>Repository: I<Entity>Repository) {}

  async execute(input: Update<Entity>Input): Promise<<Entity>DTO> {
    let entity = await this.<entity>Repository.findById(input.id, input.userId)

    if (!entity) {
      throw new <Entity>NotFoundException(input.id)
    }

    if (entity.userId !== input.userId) {
      throw new <Entity>UnauthorizedException(input.id, input.userId)
    }

    if (input.name) {
      entity = entity.updateName(input.name)
    }

    await this.<entity>Repository.save(entity)

    return <Entity>DTOMapper.fromEntity(entity)
  }
}
```

```typescript
// src/backend/application/<feature>/use-cases/Delete<Entity>UseCase.ts
import type { I<Entity>Repository } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { <Entity>NotFoundException, <Entity>UnauthorizedException } from '@/backend/domain/<feature>/exceptions'

export interface Delete<Entity>Input {
  id: string
  userId: string
}

export class Delete<Entity>UseCase {
  constructor(private readonly <entity>Repository: I<Entity>Repository) {}

  async execute(input: Delete<Entity>Input): Promise<void> {
    const entity = await this.<entity>Repository.findById(input.id, input.userId)

    if (!entity) {
      throw new <Entity>NotFoundException(input.id)
    }

    if (entity.userId !== input.userId) {
      throw new <Entity>UnauthorizedException(input.id, input.userId)
    }

    await this.<entity>Repository.delete(input.id, input.userId)
  }
}
```

### 6. Repository Implementation (Infrastructure Layer)

```typescript
// src/backend/infrastructure/<feature>/repositories/DynamoDB<Entity>Repository.ts
import { getDynamoDbTable } from '@/backend/infrastructure/database/db-config'
import { <Entity> } from '@/backend/domain/<feature>/entities/<Entity>'
import type { I<Entity>Repository, ListOptions, PaginatedResult } from '@/backend/domain/<feature>/repositories/I<Entity>Repository'
import { log } from '@/lib/logger'

export class DynamoDB<Entity>Repository implements I<Entity>Repository {
  private getModel() {
    return getDynamoDbTable().getModel('<Entity>')
  }

  async save(entity: <Entity>): Promise<void> {
    const startTime = Date.now()
    try {
      const Model = this.getModel()
      await Model.upsert(entity.toPersistence())

      log.debug('[<Entity>Repository.save] Success', {
        id: entity.id,
        duration: Date.now() - startTime,
      })
    } catch (error) {
      log.error('[<Entity>Repository.save] Failed', { error, id: entity.id })
      throw error
    }
  }

  async findById(id: string, userId: string): Promise<<Entity> | null> {
    const startTime = Date.now()
    try {
      const Model = this.getModel()
      const data = await Model.get({
        pk: `USER#${userId}`,
        sk: `<FEATURE>#<entity>#${id}`,
      })

      log.debug('[<Entity>Repository.findById] Complete', {
        id,
        found: !!data,
        duration: Date.now() - startTime,
      })

      if (!data) return null

      return <Entity>.fromPersistence(data)
    } catch (error) {
      log.error('[<Entity>Repository.findById] Failed', { error, id })
      throw error
    }
  }

  async findByUserId(userId: string, options: ListOptions = {}): Promise<PaginatedResult<<Entity>>> {
    const startTime = Date.now()
    try {
      const Model = this.getModel()
      const limit = options.limit ?? 20

      const queryOptions: any = {
        pk: `USER#${userId}`,
        sk: { begins: '<FEATURE>#<entity>#' },
        limit: limit + 1, // Fetch one extra to check for more
      }

      if (options.cursor) {
        queryOptions.start = JSON.parse(Buffer.from(options.cursor, 'base64').toString())
      }

      const results = await Model.find(queryOptions)

      const hasMore = results.length > limit
      const items = hasMore ? results.slice(0, limit) : results

      const nextCursor = hasMore && results[limit - 1]
        ? Buffer.from(JSON.stringify({ pk: results[limit - 1].pk, sk: results[limit - 1].sk })).toString('base64')
        : undefined

      log.debug('[<Entity>Repository.findByUserId] Complete', {
        userId,
        count: items.length,
        hasMore,
        duration: Date.now() - startTime,
      })

      return {
        items: items.map(data => <Entity>.fromPersistence(data)),
        nextCursor,
        hasMore,
      }
    } catch (error) {
      log.error('[<Entity>Repository.findByUserId] Failed', { error, userId })
      throw error
    }
  }

  async delete(id: string, userId: string): Promise<void> {
    const startTime = Date.now()
    try {
      const Model = this.getModel()
      await Model.remove({
        pk: `USER#${userId}`,
        sk: `<FEATURE>#<entity>#${id}`,
      })

      log.debug('[<Entity>Repository.delete] Success', {
        id,
        duration: Date.now() - startTime,
      })
    } catch (error) {
      log.error('[<Entity>Repository.delete] Failed', { error, id })
      throw error
    }
  }
}
```

### 7. DI Container Registration

```typescript
// src/backend/infrastructure/di/tokens.ts
export const TOKENS = {
  // Repositories
  <Entity>Repository: Symbol.for('<Entity>Repository'),

  // Use Cases
  Create<Entity>UseCase: Symbol.for('Create<Entity>UseCase'),
  Get<Entity>UseCase: Symbol.for('Get<Entity>UseCase'),
  List<Entity>sUseCase: Symbol.for('List<Entity>sUseCase'),
  Update<Entity>UseCase: Symbol.for('Update<Entity>UseCase'),
  Delete<Entity>UseCase: Symbol.for('Delete<Entity>UseCase'),
} as const
```

```typescript
// src/backend/infrastructure/di/container.ts
import { DynamoDB<Entity>Repository } from '../<feature>/repositories/DynamoDB<Entity>Repository'
import { Create<Entity>UseCase } from '@/backend/application/<feature>/use-cases/Create<Entity>UseCase'
import { Get<Entity>UseCase } from '@/backend/application/<feature>/use-cases/Get<Entity>UseCase'
import { List<Entity>sUseCase } from '@/backend/application/<feature>/use-cases/List<Entity>sUseCase'
import { Update<Entity>UseCase } from '@/backend/application/<feature>/use-cases/Update<Entity>UseCase'
import { Delete<Entity>UseCase } from '@/backend/application/<feature>/use-cases/Delete<Entity>UseCase'
import { TOKENS } from './tokens'

class Container {
  private instances = new Map<symbol, unknown>()
  private factories = new Map<symbol, () => unknown>()

  register<T>(token: symbol, factory: () => T): void {
    this.factories.set(token, factory)
  }

  resolve<T>(token: symbol): T {
    if (this.instances.has(token)) {
      return this.instances.get(token) as T
    }

    const factory = this.factories.get(token)
    if (!factory) {
      throw new Error(`No factory registered for token: ${String(token)}`)
    }

    const instance = factory() as T
    this.instances.set(token, instance)
    return instance
  }
}

export const DIContainer = new Container()

// Register repositories (singletons)
DIContainer.register(TOKENS.<Entity>Repository, () => new DynamoDB<Entity>Repository())

// Register use cases (with injected dependencies)
DIContainer.register(TOKENS.Create<Entity>UseCase, () =>
  new Create<Entity>UseCase(DIContainer.resolve(TOKENS.<Entity>Repository))
)
DIContainer.register(TOKENS.Get<Entity>UseCase, () =>
  new Get<Entity>UseCase(DIContainer.resolve(TOKENS.<Entity>Repository))
)
DIContainer.register(TOKENS.List<Entity>sUseCase, () =>
  new List<Entity>sUseCase(DIContainer.resolve(TOKENS.<Entity>Repository))
)
DIContainer.register(TOKENS.Update<Entity>UseCase, () =>
  new Update<Entity>UseCase(DIContainer.resolve(TOKENS.<Entity>Repository))
)
DIContainer.register(TOKENS.Delete<Entity>UseCase, () =>
  new Delete<Entity>UseCase(DIContainer.resolve(TOKENS.<Entity>Repository))
)
```

### 8. Input Schemas (Presentation Layer - Zod for shape only)

```typescript
// src/features/<feature>/schemas/<entity>-schemas.ts
import { z } from 'zod'

// Input shape validation ONLY - business rules are in Entity.validate()
export const Create<Entity>Schema = z.object({
  name: z.string().min(1, 'Name is required'),
  description: z.string().optional(),
})

export type Create<Entity>Input = z.infer<typeof Create<Entity>Schema>

export const Update<Entity>Schema = z.object({
  id: z.string().ulid(),
  name: z.string().min(1).optional(),
  description: z.string().optional(),
})

export type Update<Entity>Input = z.infer<typeof Update<Entity>Schema>

export const Get<Entity>Schema = z.object({
  id: z.string().ulid(),
})

export const List<Entity>sSchema = z.object({
  limit: z.coerce.number().min(1).max(100).optional(),
  cursor: z.string().optional(),
  status: z.enum(['active', 'inactive', 'archived']).optional(),
})

export const Delete<Entity>Schema = z.object({
  id: z.string().ulid(),
})
```

### 9. Thin Server Action Adapters (Presentation Layer)

```typescript
// src/features/<feature>/actions/create-<entity>-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { Create<Entity>Schema } from '../schemas/<entity>-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { Create<Entity>UseCase } from '@/backend/application/<feature>/use-cases'

export const create<Entity>Action = authedProcedure
  .createServerAction()
  .input(Create<Entity>Schema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<Create<Entity>UseCase>(TOKENS.Create<Entity>UseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

```typescript
// src/features/<feature>/actions/get-<entity>-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { Get<Entity>Schema } from '../schemas/<entity>-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { Get<Entity>UseCase } from '@/backend/application/<feature>/use-cases'

export const get<Entity>Action = authedProcedure
  .createServerAction()
  .input(Get<Entity>Schema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<Get<Entity>UseCase>(TOKENS.Get<Entity>UseCase)
    return useCase.execute({ id: input.id, userId: ctx.user.id })
  })
```

```typescript
// src/features/<feature>/actions/list-<entity>s-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { List<Entity>sSchema } from '../schemas/<entity>-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { List<Entity>sUseCase } from '@/backend/application/<feature>/use-cases'

export const list<Entity>sAction = authedProcedure
  .createServerAction()
  .input(List<Entity>sSchema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<List<Entity>sUseCase>(TOKENS.List<Entity>sUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

```typescript
// src/features/<feature>/actions/update-<entity>-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { Update<Entity>Schema } from '../schemas/<entity>-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { Update<Entity>UseCase } from '@/backend/application/<feature>/use-cases'

export const update<Entity>Action = authedProcedure
  .createServerAction()
  .input(Update<Entity>Schema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<Update<Entity>UseCase>(TOKENS.Update<Entity>UseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

```typescript
// src/features/<feature>/actions/delete-<entity>-action.ts
'use server'
import 'server-only'
import { authedProcedure } from '@/lib/zsa'
import { Delete<Entity>Schema } from '../schemas/<entity>-schemas'
import { DIContainer, TOKENS } from '@/backend/infrastructure/di'
import type { Delete<Entity>UseCase } from '@/backend/application/<feature>/use-cases'

export const delete<Entity>Action = authedProcedure
  .createServerAction()
  .input(Delete<Entity>Schema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<Delete<Entity>UseCase>(TOKENS.Delete<Entity>UseCase)
    return useCase.execute({ id: input.id, userId: ctx.user.id })
  })
```

### 10. Feature Index (Public Exports)

```typescript
// src/features/<feature>/index.ts

// Actions (for use in components)
export {
  create<Entity>Action,
  get<Entity>Action,
  list<Entity>sAction,
  update<Entity>Action,
  delete<Entity>Action,
} from './actions'

// DTOs (for TypeScript consumers)
export type { <Entity>DTO } from '@/backend/application/<feature>/dtos'

// Input types (for forms)
export type {
  Create<Entity>Input,
  Update<Entity>Input,
} from './schemas/<entity>-schemas'

// --------------------------------------------------------
// NEVER export: Entities, Repositories, Use Cases directly
// Consumers use actions to interact with the feature
// --------------------------------------------------------
```

## Naming Conventions

| Layer | Type | Convention | Example |
|-------|------|------------|---------|
| Domain | Entities | PascalCase | `Category.ts` |
| Domain | Interfaces | I + PascalCase | `ICategoryRepository.ts` |
| Domain | Exceptions | PascalCase + Exception | `CategoryNotFoundException.ts` |
| Application | Use Cases | VerbNounUseCase | `CreateCategoryUseCase.ts` |
| Application | DTOs | PascalCase + DTO | `CategoryDTO.ts` |
| Infrastructure | Implementations | Prefix + PascalCase | `DynamoDBCategoryRepository.ts` |
| Presentation | Actions | kebab-case | `create-category-action.ts` |
| Presentation | Schemas | kebab-case | `category-schemas.ts` |

## Anti-Patterns to Avoid

### Domain Importing Outer Layers (CRITICAL)

**BAD**:
```typescript
// src/backend/domain/category/entities/Category.ts
import { getDynamoDbTable } from '@/backend/infrastructure/database' // VIOLATION!
```

**GOOD**:
```typescript
// Domain has NO external imports
export class Category {
  private constructor(private readonly props: CategoryProps) {}
  // ...
}
```

### Fat Server Actions (CRITICAL)

**BAD**:
```typescript
export const createCategoryAction = authedProcedure
  .createServerAction()
  .input(CreateCategorySchema)
  .handler(async ({ input, ctx }) => {
    // Validation logic here...
    // Business rules here...
    // Database operations here...
    // 50+ lines
  })
```

**GOOD**:
```typescript
export const createCategoryAction = authedProcedure
  .createServerAction()
  .input(CreateCategorySchema)
  .handler(async ({ input, ctx }) => {
    const useCase = DIContainer.resolve<CreateCategoryUseCase>(TOKENS.CreateCategoryUseCase)
    return useCase.execute({ ...input, userId: ctx.user.id })
  })
```

### Direct Instantiation Instead of DI

**BAD**:
```typescript
// src/features/category/actions/create-category-action.ts
const repository = new DynamoDBCategoryRepository() // VIOLATION!
const useCase = new CreateCategoryUseCase(repository) // VIOLATION!
```

**GOOD**:
```typescript
const useCase = DIContainer.resolve<CreateCategoryUseCase>(TOKENS.CreateCategoryUseCase)
```

### Business Rules in Zod Schemas

**BAD**:
```typescript
// Zod doing business validation
export const CreateCategorySchema = z.object({
  name: z.string()
    .min(1)
    .max(255)
    .refine(name => !name.includes('banned'), 'Name contains banned words'), // Business rule!
})
```

**GOOD**:
```typescript
// Zod for shape only
export const CreateCategorySchema = z.object({
  name: z.string().min(1),
})

// Business rules in Entity
export class Category {
  private validate(): void {
    if (this.props.name.includes('banned')) {
      throw new CategoryValidationException('Name contains banned words')
    }
  }
}
```

## Verification Commands

After generating the feature, run these verification commands:

### Critical Checks (P0 - Must Pass)

```bash
FEATURE="{feature-name}"

# 1. Domain importing outer layers (MUST be empty)
echo "=== Domain Layer Violations ==="
grep -rn "from '@/backend/application\|from '@/backend/infrastructure\|from '@/features/" src/backend/domain/$FEATURE/

# 2. Application importing Infrastructure (MUST be empty)
echo "=== Application Layer Violations ==="
grep -rn "from '@/backend/infrastructure" src/backend/application/$FEATURE/

# 3. Backend importing Next.js (MUST be empty)
echo "=== Next.js in Backend Violations ==="
grep -rn "from 'next/\|from 'react\|'use server'" src/backend/

# 4. Direct instantiation in features (MUST be empty)
echo "=== Direct Instantiation Violations ==="
grep -rn "new.*UseCase(\|new.*Repository(" src/features/$FEATURE/

# 5. Fat actions (should be <10 lines in handler)
echo "=== Action Handler Sizes ==="
find src/features/$FEATURE/actions -name "*.ts" ! -name "index.ts" -exec wc -l {} \;
```

### High Priority Checks (P1)

```bash
# 6. Entity has private constructor
echo "=== Entity Constructor Check ==="
grep -n "constructor" src/backend/domain/$FEATURE/entities/*.ts

# 7. Entity has validate method
echo "=== Entity Validate Method ==="
grep -n "validate()" src/backend/domain/$FEATURE/entities/*.ts

# 8. Repository interface exists
echo "=== Repository Interface ==="
ls src/backend/domain/$FEATURE/repositories/I*.ts

# 9. DI tokens registered
echo "=== DI Tokens ==="
grep "$FEATURE" src/backend/infrastructure/di/tokens.ts
```

## Generation Process

Follow these steps in order:

1. **Gather requirements** using AskQuestion tool
2. **Create folder structure** with all necessary directories
3. **Generate Entity** with private constructor, factory methods, validate()
4. **Generate Repository Interface** in Domain layer
5. **Generate DTO** with static fromEntity()
6. **Generate Use Cases** with constructor injection
7. **Generate Repository Implementation** in Infrastructure
8. **Register in DI Container** (tokens + factories)
9. **Generate Zod Schemas** for input shape validation
10. **Generate Server Action Adapters** (3-5 lines each)
11. **Generate Feature Index** with public exports
12. **Run verification commands** to check compliance
13. **Report results** to user with next steps

## Success Criteria

A successfully generated feature should:

- Pass all verification commands (no violations)
- Have Entity with private constructor and validate()
- Have Repository interface in Domain, implementation in Infrastructure
- Have Use Cases with constructor injection
- Have thin server action adapters (3-5 lines)
- Use DI Container for all instantiation
- Have Zod schemas for input shape only (not business rules)
- Export only DTOs and actions from feature index

## References

- Feature Architecture: `skills/feature-architecture/SKILL.md`
- DynamoDB Patterns: `skills/dynamodb-onetable/SKILL.md`
- Server Actions: `skills/nextjs-server-actions/SKILL.md`
- Zod Validation: `skills/zod-validation/SKILL.md`

Overview

This skill generates complete, production-ready feature modules following Clean Architecture OOP principles for Next.js 15+ projects. It scaffolds domain entities, use cases, repository interfaces and implementations (DynamoDB/OneTable), Zod Server Actions (ZSA) adapters, a lightweight DI registration, and Vitest tests so modules are ready for production use.

How this skill works

Given feature metadata (feature name, entities, business rules, access patterns, dependencies), the generator produces a standardized folder layout and concrete TypeScript files. It creates rich domain Entities with factory methods and validation, repository interfaces plus DynamoDB/OneTable implementations, application layer use-case classes returning DTOs, thin server-action adapters, DI container bindings, and test stubs wired for Vitest. Output follows the Dependency Rule and keeps presentation adapters thin.

When to use it

  • When creating a new bounded feature from scratch (categories, accounts, billing, etc.)
  • When you need strict separation between domain, application, infrastructure, and presentation
  • When you want single-table DynamoDB design with OneTable conventions
  • When you require server actions (ZSA) that only orchestrate use cases
  • When you need testable modules with Vitest and DI-ready wiring

Best practices

  • Collect requirements first: feature name, entities, business rules, access patterns, and cross-feature dependencies
  • Keep domain logic inside Entities and domain exceptions; use cases orchestrate only domain operations
  • Design repository interfaces in the domain layer; implement them in infrastructure using DynamoDB/OneTable
  • Make server actions extremely thin—validate input with Zod, call a use case, return DTOs
  • Register concrete implementations in the DI container and resolve via constructor injection in use cases

Example use cases

  • Scaffold a new 'category' feature with Category entity, validation rules, userId access patterns, and list/query by status
  • Generate an 'account' bounded context with Create/Update/Get/Delete use cases and DynamoDB repository
  • Produce a 'notification' module where server actions validate payloads via Zod and call use cases
  • Create test skeletons for entity validation, create use-case flow, and repository persistence using Vitest

FAQ

What inputs do you need before generating a module?

Feature name (kebab-case), initial entities, key business rules, key access patterns, and any cross-feature dependencies.

Will generated server actions contain business logic?

No. Server actions are thin adapters: Zod validation, DI resolve, use-case invocation, and DTO return.

Does this enforce single-table design for DynamoDB?

Yes — repository implementations follow OneTable single-table patterns and generate item mapping and access patterns.