Database Integration Best Practices for TypeScript Applications (2024)
This comprehensive research document explores the latest best practices for database integration in modern TypeScript applications, focusing on cutting-edge ORMs, testing strategies, error handling patterns, and performance optimization techniques as of 2024.
1. Modern ORM Landscape
1.1 Prisma ORM - The Type-Safe Leader
Latest Features (2024)
- Query Compiler (Early Access): Rewritten core from Rust to TypeScript, achieving up to 500x performance improvement
- TypedSQL: Execute type-checked raw SQL queries while maintaining Prisma’s type safety
- Prisma Accelerate: Advanced connection pooling and caching, handling 670,000+ webhooks for ~$11
Performance Optimizations
// Built-in dataloader prevents N+1 queries automatically
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: true
}
}
}
})
// TypedSQL for complex queries with full type safety
const result = await prisma.$typedSQL`
SELECT u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
`Type Safety Enhancements
// Exact<Input, Shape> utility for strict type checking
import { Prisma } from '@prisma/client'
type UserCreateInput = Prisma.Exact<{
email: string
name?: string
role: 'ADMIN' | 'USER'
}, Prisma.UserCreateInput>Migration Strategies
# Generate migration files
npx prisma migrate dev --name init
# Apply migrations in production
npx prisma migrate deploy
# Pull schema from existing database
npx prisma db pull
# Push schema changes directly (development)
npx prisma db push1.2 TypeORM - The Flexible Veteran
Pattern Support
TypeORM uniquely supports both Active Record and Data Mapper patterns:
// Active Record Pattern
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
// Methods on the entity itself
async softRemove() {
this.deletedAt = new Date()
await this.save()
}
}
// Usage
const user = await User.findOne({ where: { id: 1 } })
await user.softRemove()
// Data Mapper Pattern
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}
// Separate repository
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(User)
private repository: Repository<User>
) {}
async softRemove(user: User) {
user.deletedAt = new Date()
await this.repository.save(user)
}
}Advanced Query Builders
const users = await dataSource
.createQueryBuilder(User, "user")
.leftJoinAndSelect("user.posts", "post")
.where("user.isActive = :isActive", { isActive: true })
.andWhere("post.publishedAt IS NOT NULL")
.cache(true, 60000) // Cache for 1 minute
.getMany()Caching Strategies
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
@Index() // Index for performance
email: string
// Cache configuration
@OneToMany(() => Post, post => post.user, {
cache: {
id: "user_posts",
milliseconds: 25000
}
})
posts: Post[]
}1.3 Drizzle ORM - The SQL-First Lightweight
SQL-Like Syntax
import { drizzle } from 'drizzle-orm/postgres-js'
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
// Define schema
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').unique().notNull(),
createdAt: timestamp('created_at').defaultNow()
})
// Type-safe queries that look like SQL
const result = await db
.select({
id: users.id,
name: users.name,
postCount: sql<number>`count(${posts.id})`
})
.from(users)
.leftJoin(posts, eq(users.id, posts.userId))
.groupBy(users.id)
.where(gt(users.createdAt, new Date('2024-01-01')))Migration Workflows
// drizzle.config.ts
export default {
schema: './src/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL
}
}
// Generate migrations
// bun drizzle-kit generate
// Apply migrations
// bun drizzle-kit migrate
// Database-first approach
// bun drizzle-kit pullPerformance Benefits
- Zero dependencies (~7.4kb min+gzip)
- Always outputs exactly 1 SQL query
- Serverless-ready (works with Cloudflare Workers, Deno, Bun)
- No runtime overhead or query compilation
2. Database Testing Strategies
2.1 Unit Testing with Mocks
// Using Jest to mock repository
describe('UserService', () => {
let userService: UserService
let mockRepository: jest.Mocked<Repository<User>>
beforeEach(() => {
mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn()
} as any
userService = new UserService(mockRepository)
})
it('should find user by id', async () => {
const mockUser = { id: 1, name: 'Test User' }
mockRepository.findOne.mockResolvedValue(mockUser)
const user = await userService.findById(1)
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 1 }
})
expect(user).toEqual(mockUser)
})
})2.2 Integration Testing with Real Databases
// Using Docker Compose for test databases
describe('UserRepository Integration', () => {
let dataSource: DataSource
beforeAll(async () => {
// Use test database
dataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5433, // Different port for test DB
username: 'test',
password: 'test',
database: 'test_db',
entities: [User],
synchronize: true
})
await dataSource.initialize()
})
afterAll(async () => {
await dataSource.destroy()
})
beforeEach(async () => {
// Clean database before each test
await dataSource.synchronize(true)
})
it('should create and retrieve user', async () => {
const userRepo = dataSource.getRepository(User)
const user = await userRepo.save({
name: 'Test User',
email: 'test@example.com'
})
const found = await userRepo.findOne({
where: { id: user.id }
})
expect(found).toBeDefined()
expect(found.email).toBe('test@example.com')
})
})2.3 Test Data Management
// Factory pattern for test data
class UserFactory {
static build(overrides?: Partial<User>): User {
return {
id: faker.number.int(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: new Date(),
...overrides
}
}
static async create(
overrides?: Partial<User>,
repository?: Repository<User>
): Promise<User> {
const user = this.build(overrides)
return repository.save(user)
}
}
// Usage in tests
const user = await UserFactory.create(
{ role: 'ADMIN' },
userRepository
)3. Error Handling & Transaction Management
3.1 Transaction Patterns
// Prisma transactions
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { email: 'user@example.com' }
})
const profile = await tx.profile.create({
data: {
userId: user.id,
bio: 'New user'
}
})
// If any operation fails, entire transaction rolls back
return { user, profile }
})
// TypeORM transactions
await dataSource.transaction(async manager => {
const user = await manager.save(User, {
email: 'user@example.com'
})
const profile = await manager.save(Profile, {
userId: user.id,
bio: 'New user'
})
})
// Drizzle transactions
await db.transaction(async (tx) => {
const [user] = await tx.insert(users).values({
email: 'user@example.com'
}).returning()
await tx.insert(profiles).values({
userId: user.id,
bio: 'New user'
})
})3.2 Retry Patterns
// Generic retry wrapper
async function withRetry<T>(
operation: () => Promise<T>,
options: {
maxRetries?: number
delay?: number
backoff?: number
retryOn?: (error: any) => boolean
} = {}
): Promise<T> {
const {
maxRetries = 3,
delay = 1000,
backoff = 2,
retryOn = (error) => error.code === 'ECONNREFUSED'
} = options
let lastError: any
for (let i = 0; i < maxRetries; i++) {
try {
return await operation()
} catch (error) {
lastError = error
if (!retryOn(error) || i === maxRetries - 1) {
throw error
}
const waitTime = delay * Math.pow(backoff, i)
await new Promise(resolve => setTimeout(resolve, waitTime))
}
}
throw lastError
}
// Usage with database operations
const user = await withRetry(
() => prisma.user.findUnique({ where: { id: 1 } }),
{
maxRetries: 5,
retryOn: (error) => error.code === 'P2024' // Timeout error
}
)3.3 Connection Pooling
// Prisma connection pool configuration
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL
}
},
// Connection pool settings
connectionLimit: 10,
pool: {
min: 2,
max: 10,
acquireTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
}
})
// TypeORM connection pool
const dataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "user",
password: "password",
database: "db",
// Pool configuration
extra: {
max: 10, // Maximum pool size
min: 2, // Minimum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}
})
// Drizzle with connection pooling
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
const db = drizzle(pool)4. Performance Optimization
4.1 Query Optimization
// Avoid N+1 queries with proper eager loading
// Bad - N+1 queries
const users = await prisma.user.findMany()
for (const user of users) {
const posts = await prisma.post.findMany({
where: { userId: user.id }
})
user.posts = posts
}
// Good - Single query with relations
const users = await prisma.user.findMany({
include: {
posts: {
select: {
id: true,
title: true,
publishedAt: true
}
}
}
})
// Batch operations for better performance
// Bad - Individual inserts
for (const userData of usersData) {
await prisma.user.create({ data: userData })
}
// Good - Batch insert
await prisma.user.createMany({
data: usersData,
skipDuplicates: true
})4.2 Indexing Strategies
// Prisma schema with indexes
model User {
id Int @id @default(autoincrement())
email String @unique
name String
createdAt DateTime @default(now())
// Composite index for common queries
@@index([email, createdAt])
// Partial index for active users
@@index([email], where: { isActive: true })
}
// TypeORM entity with indexes
@Entity()
@Index(['email', 'createdAt']) // Composite index
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
@Index() // Single column index
email: string
@Column()
@Index('idx_active_users', { where: 'is_active = true' }) // Partial index
isActive: boolean
}
// Drizzle schema with indexes
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull(),
createdAt: timestamp('created_at').defaultNow(),
isActive: boolean('is_active').default(true)
}, (table) => {
return {
emailIdx: index('email_idx').on(table.email),
compositeIdx: index('email_created_idx').on(table.email, table.createdAt),
partialIdx: index('active_users_idx')
.on(table.email)
.where(sql`${table.isActive} = true`)
}
})4.3 Performance Monitoring
// Prisma query logging and metrics
const prisma = new PrismaClient({
log: [
{
emit: 'event',
level: 'query',
},
],
})
prisma.$on('query', (e) => {
console.log('Query: ' + e.query)
console.log('Duration: ' + e.duration + 'ms')
// Alert on slow queries
if (e.duration > 1000) {
logger.warn('Slow query detected', {
query: e.query,
duration: e.duration,
params: e.params
})
}
})
// TypeORM query profiling
const users = await dataSource
.createQueryBuilder(User, "user")
.leftJoinAndSelect("user.posts", "post")
.where("user.isActive = :isActive", { isActive: true })
.setQueryRunner(dataSource.createQueryRunner()) // Enable profiling
.getMany()
// Access query execution time
const queryRunner = dataSource.createQueryRunner()
queryRunner.startTransaction()
// ... queries ...
const executionTime = queryRunner.getExecutionTime()5. Real-World CRUD Patterns
5.1 Repository Pattern Implementation
// Generic repository interface
interface IRepository<T> {
findById(id: number): Promise<T | null>
findAll(options?: FindOptions<T>): Promise<T[]>
create(data: Partial<T>): Promise<T>
update(id: number, data: Partial<T>): Promise<T>
delete(id: number): Promise<void>
exists(id: number): Promise<boolean>
}
// Prisma implementation
class PrismaUserRepository implements IRepository<User> {
constructor(private prisma: PrismaClient) {}
async findById(id: number): Promise<User | null> {
return this.prisma.user.findUnique({
where: { id },
include: { profile: true }
})
}
async findAll(options?: FindOptions<User>): Promise<User[]> {
return this.prisma.user.findMany({
where: options?.where,
orderBy: options?.orderBy,
skip: options?.skip,
take: options?.take,
include: { profile: true }
})
}
async create(data: CreateUserDto): Promise<User> {
return this.prisma.user.create({
data,
include: { profile: true }
})
}
async update(id: number, data: UpdateUserDto): Promise<User> {
return this.prisma.user.update({
where: { id },
data,
include: { profile: true }
})
}
async delete(id: number): Promise<void> {
await this.prisma.user.delete({ where: { id } })
}
async exists(id: number): Promise<boolean> {
const count = await this.prisma.user.count({ where: { id } })
return count > 0
}
}5.2 Batch Processing
// Batch processing with progress tracking
class BatchProcessor<T> {
constructor(
private repository: IRepository<T>,
private batchSize: number = 100
) {}
async processBatch(
items: T[],
processor: (item: T) => Promise<void>,
onProgress?: (processed: number, total: number) => void
): Promise<void> {
const total = items.length
let processed = 0
for (let i = 0; i < total; i += this.batchSize) {
const batch = items.slice(i, i + this.batchSize)
await Promise.all(
batch.map(async (item) => {
try {
await processor(item)
processed++
onProgress?.(processed, total)
} catch (error) {
console.error(`Failed to process item:`, error)
// Optionally store failed items for retry
}
})
)
// Prevent overwhelming the database
await new Promise(resolve => setTimeout(resolve, 100))
}
}
async bulkCreate(items: Partial<T>[]): Promise<void> {
for (let i = 0; i < items.length; i += this.batchSize) {
const batch = items.slice(i, i + this.batchSize)
// Prisma example
await prisma.user.createMany({
data: batch,
skipDuplicates: true
})
}
}
}5.3 Data Validation
// Zod schema for validation
import { z } from 'zod'
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().positive().max(150).optional(),
role: z.enum(['USER', 'ADMIN', 'MODERATOR']).default('USER')
})
type UserInput = z.infer<typeof UserSchema>
// Service layer with validation
class UserService {
constructor(
private repository: IRepository<User>,
private validator = UserSchema
) {}
async createUser(input: unknown): Promise<User> {
// Validate input
const validatedData = this.validator.parse(input)
// Check for existing user
const existing = await this.repository.findByEmail(validatedData.email)
if (existing) {
throw new ValidationError('Email already exists')
}
// Create user with validated data
return this.repository.create(validatedData)
}
async updateUser(id: number, input: unknown): Promise<User> {
// Partial validation for updates
const validatedData = this.validator.partial().parse(input)
// Ensure user exists
const exists = await this.repository.exists(id)
if (!exists) {
throw new NotFoundError('User not found')
}
return this.repository.update(id, validatedData)
}
}6. Claude Code Integration Patterns
6.1 Database Operations with Claude Code
// CLAUDE.md configuration for database projects
```markdown
# Project Configuration
## Architecture
- TypeScript REST API with Express.js
- PostgreSQL database with Prisma ORM
- Redis for caching and session management
## Database Schema
- users: id, email, name, role, createdAt
- posts: id, userId, title, content, publishedAt
- comments: id, postId, userId, content, createdAt
## Conventions
- Use repository pattern for data access
- Validate all inputs with Zod
- Handle errors with custom exception classes
- Use transactions for multi-table operations6.2 Automated Migration Generation
// Claude Code can generate migrations based on schema changes
interface MigrationContext {
currentSchema: string
targetSchema: string
dialect: 'postgresql' | 'mysql' | 'sqlite'
}
// Example prompt for Claude Code
const migrationPrompt = `
Given these schema changes:
- Added 'phoneNumber' field to User table
- Created new 'UserPreferences' table with userId foreign key
- Added composite index on (email, createdAt) in User table
Generate the migration SQL for PostgreSQL.
`6.3 Performance Analysis Integration
// Claude Code can analyze query performance
interface QueryAnalysis {
query: string
executionTime: number
rowsReturned: number
indexesUsed: string[]
}
// Integration with monitoring
class QueryAnalyzer {
private analyses: QueryAnalysis[] = []
async analyzeQuery(query: string): Promise<void> {
const startTime = Date.now()
const result = await executeQuery(query)
const analysis: QueryAnalysis = {
query,
executionTime: Date.now() - startTime,
rowsReturned: result.length,
indexesUsed: await this.getUsedIndexes(query)
}
this.analyses.push(analysis)
// Send to Claude Code for optimization suggestions
if (analysis.executionTime > 1000) {
const suggestion = await claudeCode.analyze({
type: 'slow-query',
analysis
})
console.log('Optimization suggestion:', suggestion)
}
}
}6.4 Further Reading: Automating Database Patterns
The patterns discussed here for integrating Claude Code with database operations can be automated and standardized using our project’s scaffolding and code generation capabilities. To learn how to create custom generators that produce code following these best practices, refer to the main AI-Powered Code Generation and Template Patterns guide.
7. Best Practices Summary
7.1 ORM Selection Criteria
| Feature | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Type Safety | Excellent (Generated) | Good (Decorators) | Excellent (Inference) |
| Performance | Good (500x with new compiler) | Good | Excellent (Minimal overhead) |
| Learning Curve | Low | Medium | Low (SQL-like) |
| Migration Tools | Excellent | Good | Good |
| Ecosystem | Large | Large | Growing |
| Serverless | Yes (with Accelerate) | Limited | Excellent |
| Bundle Size | Medium | Large | Tiny (7.4kb) |
7.2 Testing Strategy Recommendations
- Unit Tests: Mock repositories and external dependencies
- Integration Tests: Use Docker containers for real databases
- Test Data: Use factories and faker for realistic data
- Cleanup: Always clean data in beforeEach, not afterEach
- Performance: Test with realistic data volumes
7.3 Performance Checklist
- Implement proper indexing strategy
- Use connection pooling appropriately
- Batch operations when processing multiple records
- Implement retry logic for transient failures
- Monitor slow queries and optimize
- Use appropriate transaction scopes
- Implement caching where beneficial
- Avoid N+1 queries with proper eager loading
- Use pagination for large result sets
- Consider read replicas for scaling reads
7.4 Error Handling Guidelines
- Categorize Errors: Separate transient vs permanent failures
- Implement Retries: Use exponential backoff for transient errors
- Transaction Rollback: Ensure proper cleanup on failures
- Logging: Log all database errors with context
- User Feedback: Provide meaningful error messages
Conclusion
The TypeScript database ecosystem in 2024 offers mature, performant solutions for every use case. Prisma leads in developer experience and type safety, TypeORM provides flexibility with dual pattern support, and Drizzle excels in performance and simplicity.
Key trends include:
- Increased focus on serverless and edge deployment
- Enhanced type safety with better TypeScript integration
- Performance optimizations reducing overhead
- Improved developer experience with better tooling
- Greater emphasis on testing and monitoring
Choose your ORM based on your specific needs, but regardless of choice, follow established patterns for testing, error handling, and performance optimization to build robust, scalable applications.