home / skills / gilbertopsantosjr / fullstacknextjs / gs-dynamodb-onetable

gs-dynamodb-onetable skill

/skills/gs-dynamodb-onetable

This skill guides implementing a DynamoDB single-table design with OneTable ORM and Repository pattern to streamline persistence in Clean Architecture.

npx playbooks add skill gilbertopsantosjr/fullstacknextjs --skill gs-dynamodb-onetable

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

Files (1)
SKILL.md
16.6 KB
---
name: gs-dynamodb-onetable
description: Guide for DynamoDB single-table design with Repository pattern using OneTable ORM. Use when implementing Repository classes in the Infrastructure layer, designing database schemas, or handling data persistence for Clean Architecture.
---

# DynamoDB Repository Implementation with OneTable

## Architecture Context

In Clean Architecture, DynamoDB access is encapsulated in **Repository implementations** in the Infrastructure layer. The Repository pattern abstracts persistence from the Domain layer.

```
Domain Layer          Application Layer         Infrastructure Layer
┌─────────────┐      ┌─────────────────┐       ┌──────────────────────────┐
│   Entity    │ ←─── │    Use Case     │ ───→  │  Repository Interface    │
│             │      │                 │       │  (ICategoryRepository)   │
└─────────────┘      └─────────────────┘       └──────────┬───────────────┘
                                                          │
                                               ┌──────────▼───────────────┐
                                               │  DynamoDBCategoryRepo    │
                                               │  (implements interface)  │
                                               └──────────┬───────────────┘
                                                          │
                                               ┌──────────▼───────────────┐
                                               │       DynamoDB           │
                                               └──────────────────────────┘
```

## Key Design Patterns

| Access Pattern | pk | sk | Index |
|---------------|----|----|-------|
| User's items | `USER#${userId}` | `FEATURE#entity#${id}` | primary |
| Item by ID | `ITEM#${id}` | `USER#${userId}` | gsi1 |
| Hierarchical | `USER#${userId}` | `FEATURE#parent#${parentId}#${id}` | primary |
| By date | `USER#${userId}` | `FEATURE#date#${date}#${id}` | primary |
| By status | `STATUS#${status}` | `${createdAt}#${id}` | gsi1 |

## Schema Definition

```typescript
// src/backend/infrastructure/database/db-schema.ts
export const Schema = {
  format: 'onetable:1.1.0',
  version: '0.0.1',
  indexes: {
    primary: { hash: 'pk', sort: 'sk' },
    gsi1: { hash: 'gsi1pk', sort: 'gsi1sk', project: 'all' },
  },
  models: {
    Category: {
      pk: { type: String, value: 'USER#${userId}' },
      sk: { type: String, value: 'CATEGORY#category#${id}' },
      gsi1pk: { type: String, value: 'CATEGORY#${id}' },
      gsi1sk: { type: String, value: 'USER#${userId}' },
      id: { type: String, required: true, generate: 'ulid' },
      userId: { type: String, required: true },
      name: { type: String, required: true },
      description: { type: String },
      status: { type: String, enum: ['active', 'inactive', 'archived'], default: 'active' },
      createdAt: { type: String },
      updatedAt: { type: String },
    },
  },
}
```

## Entity Class (Domain Layer)

Entities define business behavior, NOT persistence:

```typescript
// src/backend/domain/category/entities/Category.ts
import { ulid } from 'ulid'
import { CategoryValidationException } from '../exceptions'

export interface CategoryProps {
  id: string
  userId: string
  name: string
  description?: string
  status: CategoryStatus
  createdAt: Date
  updatedAt: Date
}

export type CategoryStatus = 'active' | 'inactive' | 'archived'

export class Category {
  private constructor(private readonly props: CategoryProps) {
    this.validate()
  }

  static create(input: { name: string; description?: string; userId: string }): Category {
    const now = new Date()
    return new Category({
      id: ulid(),
      userId: input.userId,
      name: input.name,
      description: input.description,
      status: 'active',
      createdAt: now,
      updatedAt: now,
    })
  }

  static fromPersistence(data: Record<string, unknown>): Category {
    return new Category({
      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 CategoryStatus,
      createdAt: new Date(data.createdAt as string),
      updatedAt: new Date(data.updatedAt as string),
    })
  }

  private validate(): void {
    if (!this.props.name || this.props.name.trim().length === 0) {
      throw new CategoryValidationException('Name is required')
    }
    if (this.props.name.length > 255) {
      throw new CategoryValidationException('Name must be 255 characters or less')
    }
  }

  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(): CategoryStatus { return this.props.status }
  get createdAt(): Date { return this.props.createdAt }
  get updatedAt(): Date { return this.props.updatedAt }

  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(),
    }
  }
}
```

## Repository Interface (Domain Layer)

```typescript
// src/backend/domain/category/repositories/ICategoryRepository.ts
import type { Category } from '../entities/Category'

export interface ICategoryRepository {
  save(entity: Category): Promise<void>
  findById(id: string, userId: string): Promise<Category | null>
  findByUserId(userId: string, options?: ListOptions): Promise<PaginatedResult<Category>>
  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
}
```

## Repository Implementation (Infrastructure Layer)

**Key rules:**
- Implements interface from Domain layer
- Throws exceptions on errors (no `{success, data?, error?}` pattern)
- Uses Entity's `toPersistence()` and `fromPersistence()` methods
- Located in `src/backend/infrastructure/<feature>/repositories/`

```typescript
// src/backend/infrastructure/category/repositories/DynamoDBCategoryRepository.ts
import { getDynamoDbTable } from '@/backend/infrastructure/database/db-config'
import { Category } from '@/backend/domain/category/entities/Category'
import type {
  ICategoryRepository,
  ListOptions,
  PaginatedResult,
} from '@/backend/domain/category/repositories/ICategoryRepository'
import { log } from '@/lib/logger'

export class DynamoDBCategoryRepository implements ICategoryRepository {
  private getModel() {
    return getDynamoDbTable().getModel('Category')
  }

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

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

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

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

      if (!data) return null

      return Category.fromPersistence(data)
    } catch (error) {
      log.error('[CategoryRepository.findById] Failed', { error, id })
      throw error
    }
  }

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

      const queryOptions: any = {
        pk: `USER#${userId}`,
        sk: { begins: 'CATEGORY#category#' },
        limit: limit + 1,
      }

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

      if (options.status) {
        queryOptions.where = '${status} = {status}'
        queryOptions.substitutions = { status: options.status }
      }

      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('[CategoryRepository.findByUserId] Complete', {
        userId,
        count: items.length,
        hasMore,
        duration: Date.now() - startTime,
      })

      return {
        items: items.map(data => Category.fromPersistence(data)),
        nextCursor,
        hasMore,
      }
    } catch (error) {
      log.error('[CategoryRepository.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: `CATEGORY#category#${id}`,
      })

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

## Query Patterns

### Get by GSI (Global Secondary Index)

```typescript
async findByIdOnly(id: string): Promise<Category | null> {
  const Model = this.getModel()
  const data = await Model.get(
    { gsi1pk: `CATEGORY#${id}` },
    { index: 'gsi1' }
  )

  if (!data) return null
  return Category.fromPersistence(data)
}
```

### Query with Filter

```typescript
async findActiveByUserId(userId: string): Promise<Category[]> {
  const Model = this.getModel()
  const results = await Model.find(
    { pk: `USER#${userId}`, sk: { begins: 'CATEGORY#category#' } },
    { where: '${status} = {active}', substitutions: { active: 'active' } }
  )

  return results.map(data => Category.fromPersistence(data))
}
```

### Soft Delete Pattern

```typescript
async softDelete(id: string, userId: string): Promise<void> {
  const entity = await this.findById(id, userId)
  if (!entity) return

  const archived = entity.archive() // Entity method returns new instance
  await this.save(archived)
}
```

### Batch Operations

```typescript
async saveMany(entities: Category[]): Promise<void> {
  const Model = this.getModel()
  const batch = entities.map(e => ({
    put: e.toPersistence(),
  }))

  await Model.batchWrite(batch)
}
```

## Schema Evolution

**Adding fields:** Always optional with defaults handled in Entity:

```typescript
// Schema update - field is optional
newField: { type: String }

// Entity handles missing field
static fromPersistence(data: Record<string, unknown>): Category {
  return new Category({
    // ...
    newField: (data.newField as string) ?? 'default-value',
  })
}
```

## Key Pattern Rules

| Pattern | Format | Example |
|---------|--------|---------|
| User partition | `USER#${userId}` | `USER#01HXYZ123` |
| Entity sort key | `FEATURE#entity#${id}` | `CATEGORY#category#01HXYZ456` |
| GSI by ID | `FEATURE#${id}` | `CATEGORY#01HXYZ456` |
| Hierarchical | `FEATURE#parent#${parentId}#${id}` | `CATEGORY#parent#01HX#01HY` |
| Time-based | `FEATURE#date#${date}#${id}` | `CATEGORY#date#2024-01-15#01HX` |

## Rules Summary

1. **Use ULID for IDs** (time-sortable)
2. **Entity owns persistence format** via `toPersistence()` and `fromPersistence()`
3. **Repository throws exceptions** (not `{success, data?, error?}`)
4. **Log with timing** for observability
5. **Handle missing fields** in `fromPersistence()` with defaults
6. **No business logic** in Repository (belongs in Entity/Use Case)
7. **Interface in Domain**, implementation in Infrastructure

## Anti-Patterns

### ❌ Repository returning result objects

```typescript
// BAD - Functional pattern
async save(entity: Category): Promise<{ success: boolean; error?: string }> {
  try {
    await Model.upsert(entity.toPersistence())
    return { success: true }
  } catch (error) {
    return { success: false, error: 'Failed to save' }
  }
}
```

```typescript
// GOOD - Clean Architecture pattern
async save(entity: Category): Promise<void> {
  try {
    await Model.upsert(entity.toPersistence())
  } catch (error) {
    log.error('[CategoryRepository.save] Failed', { error })
    throw error // Let use case handle it
  }
}
```

### ❌ Business logic in Repository

```typescript
// BAD - Business rule in repository
async save(entity: Category): Promise<void> {
  if (entity.name.includes('banned')) { // Business rule!
    throw new Error('Invalid name')
  }
  await Model.upsert(entity.toPersistence())
}
```

```typescript
// GOOD - Business rule in Entity
// Entity.ts
private validate(): void {
  if (this.props.name.includes('banned')) {
    throw new CategoryValidationException('Name contains banned words')
  }
}
```

### ❌ Repository in Domain layer

```typescript
// BAD - Implementation in domain
// src/backend/domain/category/DynamoDBCategoryRepository.ts
import { getDynamoDbTable } from '@/backend/infrastructure/database' // VIOLATION!
```

```typescript
// GOOD - Only interface in domain
// src/backend/domain/category/repositories/ICategoryRepository.ts
export interface ICategoryRepository {
  save(entity: Category): Promise<void>
  // ...
}

// Implementation in infrastructure
// src/backend/infrastructure/category/repositories/DynamoDBCategoryRepository.ts
export class DynamoDBCategoryRepository implements ICategoryRepository { ... }
```

## DI Container Registration

```typescript
// src/backend/infrastructure/di/container.ts
import { DynamoDBCategoryRepository } from '../category/repositories/DynamoDBCategoryRepository'
import { TOKENS } from './tokens'

// Register repository as singleton
DIContainer.register(TOKENS.CategoryRepository, () => new DynamoDBCategoryRepository())
```

## Testing Repositories

```typescript
// src/test/category/repositories/DynamoDBCategoryRepository.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { DynamoDBCategoryRepository } from '@/backend/infrastructure/category/repositories'
import { Category } from '@/backend/domain/category/entities'
import { setupTestDb, clearTestData } from '@/test/db-helpers'

describe('DynamoDBCategoryRepository', () => {
  const repository = new DynamoDBCategoryRepository()
  const testUserId = '01HXYZ123456789ABCDEFGHIJK'

  beforeEach(async () => {
    await clearTestData('Category')
  })

  describe('save', () => {
    it('persists a new category', async () => {
      const category = Category.create({
        name: 'Test Category',
        userId: testUserId,
      })

      await repository.save(category)

      const found = await repository.findById(category.id, testUserId)
      expect(found).not.toBeNull()
      expect(found?.name).toBe('Test Category')
    })
  })

  describe('findById', () => {
    it('returns null for non-existent category', async () => {
      const found = await repository.findById('nonexistent', testUserId)
      expect(found).toBeNull()
    })
  })

  describe('findByUserId', () => {
    it('returns paginated results', async () => {
      // Create test data
      await Promise.all([
        repository.save(Category.create({ name: 'Cat 1', userId: testUserId })),
        repository.save(Category.create({ name: 'Cat 2', userId: testUserId })),
        repository.save(Category.create({ name: 'Cat 3', userId: testUserId })),
      ])

      const result = await repository.findByUserId(testUserId, { limit: 2 })

      expect(result.items).toHaveLength(2)
      expect(result.hasMore).toBe(true)
      expect(result.nextCursor).toBeDefined()
    })
  })
})
```

## References

- Clean Architecture: `skills/feature-architecture/SKILL.md`
- Create Domain Module: `skills/create-domain-module/SKILL.md`
- Testing: `skills/create-e2e-tests/SKILL.md`

Overview

This skill is a practical guide for implementing DynamoDB single-table design using the OneTable ORM together with the Repository pattern. It explains how to map domain entities to a single-table schema, implement repository classes in the Infrastructure layer, and keep business rules inside domain entities. The guide focuses on predictable key patterns, schema evolution, observability, and Clean Architecture rules.

How this skill works

The guide defines a OneTable schema with primary and GSI indexes and shows mapping rules for pk/sk and GSI keys (user partition, entity sort, hierarchical and time-based patterns). It provides a Domain entity that owns persistence conversion (toPersistence/fromPersistence) and a Repository implementation that uses the OneTable Model to upsert, get, find, batchWrite, and remove items. Repositories throw errors, log timings, and translate persistence records into domain entities.

When to use it

  • Implementing Repository classes in the Infrastructure layer for Clean Architecture
  • Designing a DynamoDB single-table schema with OneTable ORM
  • Mapping domain entities to persistence (toPersistence/fromPersistence)
  • Querying by partition, GSI, hierarchical or time-based patterns
  • Implementing paginated list endpoints with cursor-based pagination

Best practices

  • Keep business rules in Entities or Use Cases; repositories only handle persistence
  • Use ULID for IDs to ensure time-sortable keys
  • Entity owns persistence format and handles defaulting/missing fields in fromPersistence
  • Throw exceptions from repositories; let application layer handle errors
  • Log operation timing and context for observability (start, duration, success/failure)

Example use cases

  • Save a Category entity via repository.save(entity) which upserts the single-table record
  • Find a user’s categories using pk = USER#${userId} and sk begins with the entity prefix
  • Fetch an entity by id using a GSI where gsi1pk = FEATURE#${id}
  • Implement soft-delete by creating an archived entity in the domain and saving it
  • Batch import many domain entities with Model.batchWrite using entities’ toPersistence()

FAQ

Where should the repository interface live?

Keep the interface in the Domain layer and implement it in the Infrastructure layer so domain code does not depend on persistence details.

How do I handle schema changes without breaking reads?

Make new fields optional in the schema and provide sensible defaults in Entity.fromPersistence to tolerate missing values.