The Persistence Layer manages all database interactions using Drizzle ORM with PostgreSQL. It implements the Repository pattern with Data Access Objects (DAO) and maintains clean separation between domain types and database types.
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.
The layer is organized with clear separation between models, repositories, and type definitions:
Persistence Layer Organization:
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
Key Patterns:
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
})
}Clean separation between database types and domain types:
Domain Types vs Database Types:
// 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',
}Database Types (Drizzle Generated):
// 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[]
}Critical Setup: Before running migrations, enable UUID extension in your PostgreSQL database:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";Drizzle migrations provide schema evolution with version control:
Drizzle Configuration:
// 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 ConfigPackage.json Scripts:
{
"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"
}
}Migration Workflow:
# 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:studioDatabase performance considerations and optimization patterns:
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.