Layer Responsibility: The Persistence Layer handles all database operations, schema definitions, migrations, and data transformations. It never contains business logic or authorization checks.
Perfect for: Type-safe database operations, maintainable schema evolution, efficient queries, and clean data access patterns with proper type transformations.
Persistence Layer Architecture
The layer is organized with clear separation between models, repositories, and type definitions:Persistence Layer Organization:Key Patterns:
src/db/ # 📁 Database Layer Root
├── models/ # 📋 Schema Definitions
│ ├── db.ts # Drizzle instance & schema export
│ ├── user-model.ts # User table schema
│ ├── subscription-model.ts # Subscription table schema
│ ├── organization-model.ts # Organization table schema
│ └── index.ts # All models export
├── repositories/ # 🗄️ Data Access Objects (DAO)
│ ├── user-repository.ts # User database operations
│ ├── subscription-repository.ts
│ └── organization-repository.ts
├── migrations/ # 🔄 Database Migrations
│ ├── 0001_create_users.sql
│ ├── 0002_create_subscriptions.sql
│ └── meta/
├── helpers/ # 🛠️ Database Utilities
│ ├── query-helper.ts # Common query patterns
│ ├── transform-helper.ts # Type transformations
│ └── transaction-helper.ts # Transaction utilities
└── types/ # 📝 Database Types
├── models.ts # Inferred Drizzle types
└── common.ts # Common database types
- Repository Pattern - DAO for each domain entity
- Type Separation - Domain types vs Drizzle types
- Schema Evolution - Migration-based changes
- Connection Pooling - Optimized database connections
Repository Pattern (DAO)
Repositories encapsulate all database operations for specific entities:User Repository Example:
// src/db/repositories/user-repository.ts
import { eq, and, desc, count, sql } from 'drizzle-orm'
import db from '../models/db'
import { users, type UserModel, type NewUserModel } from '../models/user-model'
import { organizations } from '../models/organization-model'
// ===== CREATE OPERATIONS =====
export async function createUserDao(userData: NewUserModel): Promise<UserModel> {
const [newUser] = await db
.insert(users)
.values({
...userData,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
return newUser
}
export async function createUsersDao(userDataArray: NewUserModel[]): Promise<UserModel[]> {
return await db
.insert(users)
.values(userDataArray.map(userData => ({
...userData,
createdAt: new Date(),
updatedAt: new Date(),
})))
.returning()
}
// ===== READ OPERATIONS =====
export async function getUserByIdDao(id: string): Promise<UserModel | null> {
const user = await db.query.users.findFirst({
where: eq(users.id, id),
with: {
organization: true,
subscriptions: {
where: eq(subscriptions.status, 'active'),
orderBy: desc(subscriptions.createdAt),
},
},
})
return user || null
}
export async function getUserByEmailDao(email: string): Promise<UserModel | null> {
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
})
return user || null
}
export async function getUsersDao(
filters: {
organizationId?: string
role?: string
isActive?: boolean
limit?: number
offset?: number
} = {}
): Promise<{
users: UserModel[]
totalCount: number
}> {
const conditions = []
if (filters.organizationId) {
conditions.push(eq(users.organizationId, filters.organizationId))
}
if (filters.role) {
conditions.push(eq(users.role, filters.role))
}
if (filters.isActive !== undefined) {
conditions.push(eq(users.isActive, filters.isActive))
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
// Get users with pagination
const userResults = await db.query.users.findMany({
where: whereClause,
limit: filters.limit || 50,
offset: filters.offset || 0,
orderBy: desc(users.createdAt),
with: {
organization: true,
},
})
// Get total count
const [{ count: totalCount }] = await db
.select({ count: count() })
.from(users)
.where(whereClause)
return {
users: userResults,
totalCount,
}
}
// ===== UPDATE OPERATIONS =====
export async function updateUserDao(
id: string,
updates: Partial<NewUserModel>
): Promise<UserModel> {
const [updatedUser] = await db
.update(users)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(users.id, id))
.returning()
return updatedUser
}
export async function updateUserMetadataDao(
id: string,
metadata: Record<string, any>
): Promise<UserModel> {
const [updatedUser] = await db
.update(users)
.set({
metadata: JSON.stringify(metadata),
updatedAt: new Date(),
})
.where(eq(users.id, id))
.returning()
return updatedUser
}
// ===== DELETE OPERATIONS =====
export async function deleteUserDao(id: string): Promise<UserModel> {
const [deletedUser] = await db
.delete(users)
.where(eq(users.id, id))
.returning()
return deletedUser
}
export async function softDeleteUserDao(id: string): Promise<UserModel> {
return await updateUserDao(id, {
isActive: false,
email: `deleted_${Date.now()}_${id}@deleted.com` // Unique constraint handling
})
}Type Transformations
Clean separation between database types and domain types:Domain Types vs Database Types:Database Types (Drizzle Generated):
// src/services/types/domain/user-types.ts
// Domain types used by services and presentation layer
export interface User {
id: string
name: string
email: string
role: UserRole
isActive: boolean
createdAt: Date
updatedAt: Date
// Computed/transformed fields
displayName: string
avatarUrl?: string
permissions: Permission[]
// Related data (when needed)
organization?: Organization
activeSubscription?: Subscription
}
export interface CreateUserInput {
name: string
email: string
password: string
role?: UserRole
organizationId?: string
}
export interface UpdateUserInput {
id: string
name?: string
email?: string
role?: UserRole
isActive?: boolean
}
// Enums for domain logic
export enum UserRole {
GUEST = 'GUEST',
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMIN = 'ADMIN',
SUPER_ADMIN = 'SUPER_ADMIN',
}// src/db/types/models.ts
// Auto-generated from Drizzle schemas
import type { users, subscriptions } from '../models'
export type UserModel = typeof users.$inferSelect
export type NewUserModel = typeof users.$inferInsert
export type SubscriptionModel = typeof subscriptions.$inferSelect
// Database-specific types
export interface UserWithRelations extends UserModel {
organization?: OrganizationModel
subscriptions: SubscriptionModel[]
}Database Migrations
Critical Setup: Before running migrations, enable UUID extension in your PostgreSQL database:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";Drizzle Configuration:Package.json Scripts:Migration Workflow:
// drizzle.config.ts
import type { Config } from 'drizzle-kit'
import { env } from './src/env'
export default {
schema: './src/db/models/*-model.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: env.DATABASE_URL,
},
verbose: true,
strict: true,
} satisfies Config{
"scripts": {
"db:generate": "drizzle-kit generate:pg",
"db:migrate": "drizzle-kit push:pg",
"db:studio": "drizzle-kit studio",
"db:drop": "drizzle-kit drop",
"db:check": "drizzle-kit check:pg",
"db:up": "drizzle-kit up:pg"
}
}# 1. Modify schema in model files
# 2. Generate migration files
npm run db:generate
# 3. Review generated SQL
# 4. Apply migrations to database
npm run db:migrate
# 5. Verify changes in Drizzle Studio
npm run db:studioPerformance Optimization
Database performance considerations and optimization patterns:🚀
Query Optimization
• Selective loading with Drizzle queries
• Proper indexing on frequently queried columns
• Batch operations for bulk data changes
• Query analysis with EXPLAIN ANALYZE
• Connection pooling for scalability
💾
Data Access Patterns
• Repository pattern for consistent access
• Type transformations for clean interfaces
• Pagination for large datasets
• Eager loading vs lazy loading strategies
• Transaction optimization for consistency
🔍
Monitoring & Analytics
• Query performance tracking
• Slow query identification
• Database metrics collection
• Connection pool monitoring
• Error rate tracking
🛡️
Data Security
• SQL injection prevention with prepared statements
• Data encryption for sensitive fields
• Access logging for audit trails
• Row-level security where appropriate
• Connection security with TLS
Best Practices
Repository Design:
// ✅ Good: Focused, single-responsibility repositories
export class UserRepository {
async findById(id: string): Promise<User | null> { }
async findByEmail(email: string): Promise<User | null> { }
async create(user: CreateUserInput): Promise<User> { }
async update(id: string, updates: UpdateUserInput): Promise<User> { }
async delete(id: string): Promise<void> { }
}
// ✅ Good: Use appropriate return types
async function getUserDao(id: string): Promise<UserModel | null> {
// Return null for not found, not undefined
return user || null
}
// ✅ Good: Consistent error handling
async function updateUserDao(id: string, data: UpdateUserInput): Promise<UserModel> {
const [updated] = await db.update(users)
.set(data)
.where(eq(users.id, id))
.returning()
if (!updated) {
throw new Error(`User ${id} not found`)
}
return updated
}
// ❌ Bad: Generic repositories lose type safety
export class GenericRepository<T> {
async findById(id: string): Promise<T | null> { } // Too generic
}Persistence layer ready! Your database operations are type-safe, performant, and maintainable with proper separation between domain and database concerns using Drizzle ORM.