Skip to main content

Chapter 5: Database Integration

Integrating a database is a core part of any backend application. NestJS supports multiple ORMs, with TypeORM and Prisma being the most popular. This chapter covers setup, modeling, querying, transactions, migrations, relationships, and best practices for both. We’ll walk through the process step by step, so you can confidently connect your app to a database.

5.1 Choosing an ORM

Before diving into setup, understand the differences between TypeORM and Prisma to choose the right tool for your project.

TypeORM vs Prisma

Choosing an ORM is one of those decisions that is hard to reverse later, so it pays to understand the trade-offs upfront. Think of it this way: TypeORM is like a Swiss Army knife — lots of tools, lots of flexibility, but you need to know which blade to pull out. Prisma is like a purpose-built power tool — fewer options, but the ones it has are exceptionally well-designed and type-safe. TypeORM:
  • Active Record and Data Mapper patterns — you choose the architectural style that fits your team
  • Decorator-based entity definitions — feels natural if you are coming from NestJS or Angular
  • Mature ecosystem with many features (subscribers, listeners, custom repositories)
  • Flexible query builder for complex SQL that ORMs usually struggle with
  • Works with TypeScript and JavaScript — broader adoption
  • Good for complex queries, legacy databases, and teams that want fine-grained SQL control
Prisma:
  • Type-safe database client — your IDE catches query errors before you even run the code
  • Schema-first approach — a single schema.prisma file is the source of truth for your data model
  • Excellent developer experience — Prisma Studio for visual data browsing, automatic migrations
  • Automatic client generation — run prisma generate and get a perfectly typed client
  • Great for rapid development and teams that prioritize type safety over SQL flexibility
  • Strong TypeScript support — arguably the best TypeScript database experience available
When to Use TypeORM:
  • Complex queries with joins, subqueries, and raw SQL
  • You prefer the decorator pattern (consistent with the rest of NestJS)
  • Working with an existing database schema you cannot change
  • Need database-specific features (stored procedures, materialized views)
When to Use Prisma:
  • Starting a new project with a clean schema
  • Type safety is a top priority (fewer runtime query errors)
  • Rapid prototyping — schema changes and migrations are fast
  • Your team is less experienced with raw SQL
Honest Take: If I am starting a greenfield NestJS project in 2025, I reach for Prisma first. The developer experience and type safety are hard to beat. But if I am working with a legacy database or need complex multi-table joins, TypeORM’s query builder gives me more control where it matters.

Detailed Feature Comparison

Migration Strategy Decision Framework


5.2 Setting Up TypeORM

TypeORM is a mature ORM that works seamlessly with NestJS. Let’s set it up step by step.

Installation

Basic Configuration

Using Configuration Module

Better approach using @nestjs/config:
Diagram: TypeORM Integration

5.3 Defining Entities with TypeORM

Entities define your database structure using decorators.

Basic Entity

Entities are TypeScript classes that map to database tables. Each property decorated with @Column() becomes a column in the table. Think of an entity as a contract between your code and your database — it says “this table has these columns with these types.”

Column Options

Relationships

One-to-Many:
Many-to-Many:
One-to-One:

5.4 Using Repositories with TypeORM

Repositories provide methods to interact with entities.

Injecting Repository

Using Repository in Service

Advanced Queries

Query Builder:
Relations:
Pagination:

5.5 Setting Up Prisma

Prisma provides a type-safe database client with excellent developer experience.

Installation

Schema Definition

Prisma Service

The PrismaService is the bridge between NestJS’s DI system and Prisma’s client. By extending PrismaClient and implementing NestJS lifecycle hooks, we get automatic connection management — the database connects when the module initializes and disconnects when the app shuts down.
Practical Tip: Register the PrismaService in a @Global() PrismaModule so every feature module can inject it without explicit imports. This mirrors how @nestjs/config’s ConfigModule works.

Registering Prisma Service

Diagram: Prisma Integration

5.6 Using Prisma in Services

Prisma provides a type-safe, intuitive API for database operations.

Basic CRUD Operations

Advanced Queries

Including Relations:
Filtering:
Pagination:
Complex Queries:

5.7 Transactions

Transactions ensure multiple operations succeed or fail together.

TypeORM Transactions

Using DataSource:
Using Query Runner:

Prisma Transactions

Sequential Operations:
Batch Operations:
Interactive Transactions:

Transaction Edge Cases

Edge Case 1: Transactions and connection pool exhaustion Every open transaction holds a database connection. If your transaction calls an external API (payment gateway, email service) that takes 10 seconds, that connection is blocked for 10 seconds. Under load, this exhausts the connection pool and cascades into failures for all requests. Rule: keep transactions as short as possible. Do external calls before or after the transaction, not inside it.
Edge Case 2: Prisma’s $transaction timeout Prisma interactive transactions have a default timeout of 5 seconds. If your transaction exceeds this, it is automatically rolled back. Increase it for complex operations:
Edge Case 3: TypeORM save() vs insert() in transactions save() does a SELECT first to check if the entity exists, then INSERT or UPDATE. Inside a tight loop, this doubles your queries. Use insert() when you know the entity is new, and update() when you know it exists. save() is convenient but expensive at scale.

5.8 Migrations

Migrations keep your database schema in sync with your code.

TypeORM Migrations

Generate Migration:
Migration File:
Run Migrations:

Prisma Migrations

Create Migration:
Apply Migrations:
Reset Database:
Generate Client: After schema changes:

5.9 Best Practices

Following best practices ensures your database integration is robust and maintainable.

Use Environment Variables

Never hard-code database credentials:

Connection Pooling

Configure connection pooling for production:

Use Migrations

Never use synchronize: true in production:

Index Optimization

Add indexes for frequently queried columns:

Query Optimization

TypeORM:
Prisma:

Query Performance Decision Table

Error Handling

Database errors are cryptic by default — your users should never see “ERROR: duplicate key value violates unique constraint.” Translate database-level errors into meaningful HTTP exceptions.
Common Mistake: Catching all errors silently and returning a generic 500. Always translate known error codes (unique violations, foreign key errors) into specific 4xx responses so your API consumers can handle them programmatically.

Testing

Use test databases for integration tests:

5.10 Summary

You’ve learned how to integrate databases with NestJS: Key Concepts:
  • TypeORM: Decorator-based ORM with flexible queries
  • Prisma: Type-safe database client with excellent DX
  • Entities/Models: Define database structure
  • Repositories: Abstract data access
  • Relationships: One-to-one, one-to-many, many-to-many
  • Transactions: Ensure data consistency
  • Migrations: Version control for database schema
Best Practices:
  • Use environment variables for configuration
  • Use migrations, never synchronize in production
  • Optimize queries and add indexes
  • Handle errors gracefully
  • Use connection pooling in production
  • Write integration tests
Next Chapter: Learn about authentication, authorization, JWT, OAuth, and security best practices.

Interview Deep-Dive

Strong Answer:
  • The decision starts with the team and project constraints. If the team is experienced with SQL and needs complex joins, raw queries, or stored procedures, TypeORM’s query builder gives more control. If the team prioritizes developer experience and type safety, Prisma’s generated client catches query errors at compile time.
  • For greenfield projects, I lean toward Prisma. The schema-first approach means a single schema.prisma file is the source of truth. Migrations are automatic, the generated client is fully typed, and Prisma Studio provides a visual data browser for debugging.
  • TypeORM’s strength is flexibility with legacy databases. If I am integrating with an existing database that has 200 tables, custom naming conventions, and stored procedures, TypeORM handles it better. Prisma’s introspection can reverse-engineer a schema, but complex views and stored procedures require workarounds.
  • The setup includes: connection pooling (max connections based on available connections divided by app instances), retry logic, and a health check that pings the database.
Follow-up: Six months after choosing Prisma, the team needs a complex recursive CTE query. What do you do?Prisma supports raw queries via prisma.$queryRaw. For a recursive CTE, write the raw SQL and type the result manually. This loses Prisma’s type safety for that specific query, but it is a pragmatic escape hatch. If the team needs many raw queries, consider using Prisma for simple CRUD and a lightweight query builder like Kysely for complex queries — they can share the same connection pool.
Strong Answer:
  • A transaction ensures multiple database operations either all succeed or all fail. In NestJS with TypeORM, use dataSource.transaction(async (manager) => { ... }). The critical mistake is using the regular repository inside the callback — those queries run outside the transaction.
  • In Prisma, use prisma.$transaction(async (tx) => { ... }). The gotcha: Prisma’s interactive transactions have a default timeout of 5 seconds. If your transaction takes longer, it is automatically rolled back. Increase with { timeout: 30000 }.
  • Another gotcha: the array form prisma.$transaction([...]) runs operations in parallel without conditional logic. The callback form runs sequentially with if/else support.
  • The production rule: keep transactions short. Never call external APIs inside a transaction — they hold database connections and locks. Fetch external data before starting the transaction, then do all writes inside it.
Follow-up: How would you implement optimistic locking in NestJS to prevent concurrent update conflicts?Add a version column to the entity. When updating, include the version in the WHERE clause: UPDATE users SET name = 'new', version = version + 1 WHERE id = 1 AND version = 5. If another user changed the record, the WHERE matches zero rows. In Prisma, use prisma.user.update({ where: { id: 1, version: 5 }, data: { name: 'new', version: { increment: 1 } } }). Prisma throws P2025 if no rows match, which you catch and return 409 Conflict.
Strong Answer:
  • Step 1: Enable query logging. In TypeORM: logging: true. In Prisma: prisma.$on('query', (e) => console.log(e.query, e.duration)). This shows every query and its duration.
  • Step 2: Look for N+1 queries — the most common ORM performance problem. Loading 100 users then loading each user’s posts in a loop is 101 queries. Fix with eager loading: TypeORM relations: ['posts'], Prisma include: { posts: true }.
  • Step 3: Check missing indexes. Run EXPLAIN ANALYZE on slow queries. “Seq Scan” on millions of rows means you need an index. TypeORM: @Index(). Prisma: @@index([email]).
  • Step 4: Optimize SELECT. Only fetch needed columns. Prisma: select: { id: true, name: true }.
  • Step 5: Add pagination. Never load all rows from large tables.
  • Step 6: Consider caching frequent, rarely-changing queries in Redis.
Follow-up: How do you handle the N+1 problem specifically in a GraphQL NestJS application?GraphQL makes N+1 worse because the client controls which fields to fetch. The solution is DataLoader, which batches individual lookups into a single query per event loop tick. When a resolver calls dataLoader.load(userId), DataLoader collects all requested IDs and fires one WHERE author_id IN (1, 2, 3, ...) query. Register DataLoaders as request-scoped providers to avoid cache leaks between requests.
Strong Answer:
  • synchronize: true tells TypeORM to auto-alter the schema on every startup. If you rename a column, TypeORM drops the old column (destroying data) and creates a new one. No rollback, no confirmation, no audit trail.
  • I witnessed a production incident where adding nullable: true to a column triggered an ALTER TABLE on a 50-million-row table, locking it for 12 minutes and causing a complete API outage.
  • The correct workflow: TypeORM migration:generate compares entities to the database and generates SQL. You review it, commit to version control, and run with migration:run. In Prisma: prisma migrate dev creates migration files, prisma migrate deploy applies them in production.
  • My CI pipeline: (1) Run migrations against a test database. (2) Run tests. (3) Build Docker image. (4) During deployment, run migrations before starting the new app version. (5) If migration fails, roll back the deployment.
Follow-up: How do you handle a migration that takes 30 minutes on a 100-million-row table?Never run long migrations during deployment. Use online schema migration tools like pg_repack (PostgreSQL) or pt-online-schema-change (MySQL) that copy the table without locking. For PostgreSQL 11+, ALTER TABLE ADD COLUMN ... DEFAULT ... is instant (metadata-only). For indexes, use CREATE INDEX CONCURRENTLY. Plan the strategy before writing the migration file.