gs-dynamodb-onetable
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
// 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:
// 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)
// 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()andfromPersistence()methods - Located in
src/backend/infrastructure/<feature>/repositories/
// 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)
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
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
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
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:
// 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
- Use ULID for IDs (time-sortable)
- Entity owns persistence format via
toPersistence()andfromPersistence() - Repository throws exceptions (not
{success, data?, error?}) - Log with timing for observability
- Handle missing fields in
fromPersistence()with defaults - No business logic in Repository (belongs in Entity/Use Case)
- Interface in Domain, implementation in Infrastructure
Anti-Patterns
❌ Repository returning result objects
// 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' }
}
}
// 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
// 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())
}
// 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
// BAD - Implementation in domain
// src/backend/domain/category/DynamoDBCategoryRepository.ts
import { getDynamoDbTable } from '@/backend/infrastructure/database' // VIOLATION!
// 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
// 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
// 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
More from gilbertopsantosjr/fullstacknextjs
tanstack-react-query
TanStack React Query expert for data fetching and mutations in React applications. Use when working with useQuery, useMutation, cache invalidation, optimistic updates, query keys, or integrating server actions with React Query via @saas4dev/core hooks (useServerActionQuery, useServerActionMutation, useServerActionInfiniteQuery). Triggers on requests involving API data fetching, server state management, cache strategies, or converting fetch/useEffect patterns to React Query.
4gs-feature-architecture
Guide for implementing features in Clean Architecture OOP with Next.js. Use when planning new features, understanding the 4-layer structure (Domain, Application, Infrastructure, Presentation), or deciding where code should live.
3zod-validation
Guide for Zod schema validation patterns in TypeScript. Use when creating validation schemas, defining types, validating forms, API inputs, or handling validation errors.
2gs-sst-infra
Guide for AWS serverless infrastructure using SST v3. Covers DynamoDB, Next.js deployment, Lambda handlers with Clean Architecture adapter pattern, and CI/CD configuration.
2feature-architecture
Guide for implementing features in a layered Next.js full-stack architecture. Use when planning new features, understanding the layer structure (Model, DAL, Service, Actions, Components, Pages), or deciding where code should live.
2gs-santry-observability
Sentry observability for Clean Architecture layers. Error tracking per layer, transaction tracing for Use Cases, user context in procedures, and performance monitoring.
1