open atlas
↑ Back to track
NestJS, zero to senior NEST · 04 · 01

TypeORM or Prisma: integrating an ORM

TypeORM and Prisma both wire an ORM into Nest, but the type-safety source of truth differs: decorator @Entity classes registered through TypeOrmModule, or a schema.prisma that generates a client you wrap as a PrismaService.

NEST Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A new service needs a database, and the first PR on the repo decides the next two years. One reviewer wants @Entity() classes — “the model is the TypeScript, that’s where it belongs.” Another wants a schema.prisma — “the schema is the source of truth, the client is generated, you can’t drift.” Both will work. But this isn’t a taste argument: the two paths put the type-safety source of truth in different places, hand migrations to different tools, and integrate with Nest’s dependency injection in fundamentally different ways. One gives you DI-native repositories; the other gives you one generated client you must wrap yourself. Pick by the constraint, not the vibe.

TypeORM: entities are the truth, repositories are injected

In the TypeORM path you configure a single DataSource once at the root, then register entities per feature module. The connection is async because its values come from ConfigService, so you reach for TypeOrmModule.forRootAsync with a useFactory rather than the static forRoot:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from './user.entity';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('DB_HOST'),
        port: config.get<number>('DB_PORT'),
        username: config.get('DB_USER'),
        password: config.get('DB_PASS'),
        database: config.get('DB_NAME'),
        entities: [User],
        synchronize: false, // NEVER true in production
      }),
    }),
  ],
})
export class AppModule {}

The entity is a plain class decorated with @Entity(), and its columns are the schema:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column({ default: true })
  isActive: boolean;
}

That decorator metadata is the type-safety source of truth — the TypeScript class drives both the runtime column mapping and the migration generator. To use the entity in a feature module you register it with forFeature, which provides a repository for it into the DI container, and you inject that repository with @InjectRepository:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';

@Module({
  imports: [TypeOrmModule.forFeature([User])], // provides Repository<User>
  providers: [UsersService],
})
export class UsersModule {}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly users: Repository<User>,
  ) {}

  findActive(): Promise<User[]> {
    return this.users.find({ where: { isActive: true } });
  }
}

The Repository&lt;User> is the persistence boundary: your service talks to it, never to raw SQL or a global client. This is DI-native — the repository is a normal Nest provider, mockable in tests by overriding the getRepositoryToken(User) provider.

Prisma: schema is the truth, you wrap the client yourself

Prisma inverts the source of truth. A single schema.prisma file declares the models; prisma generate reads it and emits a fully-typed client. You never write entity classes — the schema generates the types:

// schema.prisma
generator client {
  provider = "prisma-client-js"
}

model User {
  id       Int     @id @default(autoincrement())
  email    String  @unique
  isActive Boolean @default(true)
}

Nest has no first-class Prisma DI provider, so you supply one: an injectable PrismaService that extends the generated PrismaClient and opens the connection in onModuleInit. This is the integration glue you write by hand:

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

Provide it once, then inject the single service — there is no per-entity @InjectRepository, just typed model accessors on the client:

import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { User } from '@prisma/client';

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  findActive(): Promise<User[]> {
    return this.prisma.user.findMany({ where: { isActive: true } });
  }
}

Note what’s missing: no forFeature, no repository token, no decorator on a model. The whole API surface is this.prisma.&lt;model>.&lt;operation>, and the User type comes from @prisma/client, regenerated every time the schema changes.

The real decision: where the truth lives

When you’re evaluating these options in a real PR, the vibe arguments stop mattering fast — the constraints don’t. Neither is universally correct — they differ on three axes a senior weighs against a stated constraint. The type-safety source of truth is the first: TypeScript decorator entities (TypeORM) versus a .prisma schema that generates the client (Prisma). Migrations follow from that: TypeORM generates and runs migration files derived from your entities; Prisma Migrate diffs the schema and produces SQL migrations. And DI ergonomics diverge: TypeORM gives you per-entity repositories as first-class Nest providers; Prisma gives you one client you wrap and inject everywhere.

AxisTypeORMPrisma
Source of truthDecorator @Entity() TS classesschema.prisma generates the client
MigrationsGenerate + run migration files from entitiesPrisma Migrate diffs schema → SQL
DI ergonomicsPer-entity Repository<T>, injectedOne PrismaService you write + inject
Nest integrationFirst-class @nestjs/typeorm moduleNo first-class DI — you provide it
Why this works

Why is synchronize: true the one switch you must never flip in production? It tells TypeORM to alter the live schema on every boot to match your entities — silently, with no migration file and no review. Rename a property and the old column is dropped along with its data; the change ships the moment the process restarts, with no record of what happened. It is a convenient toy for a throwaway local sketch and a data-loss incident waiting in staging or prod. The disciplined path is synchronize: false plus generated migration files you review and run deliberately.

Pick the best fit

Your team wants the database schema itself to be the single source of truth, with fully generated TypeScript types that can't drift from the schema, and is comfortable writing one service wrapper. Which persistence layer fits that constraint?

Quiz

In the TypeORM path, how does a feature module's service get a Repository<User> to query with?

Quiz

Why must a PrismaService implement OnModuleInit and call await this.$connect()?

Recall before you leave
  1. 01
    Walk the full TypeORM wiring from connection to query: what configures the DataSource, what registers an entity per module, and how does a service get its repository?
  2. 02
    Walk the full Prisma wiring: where do the types come from, why must you write a PrismaService, and how does a service query?
Recap

Both TypeORM and Prisma integrate an ORM into Nest, but they place the type-safety source of truth in different homes, which cascades into migrations and DI. In the TypeORM path, decorator @Entity() classes are the truth: you configure the DataSource once with TypeOrmModule.forRootAsync and a useFactory fed by ConfigService (with synchronize: false), register each entity per feature module with TypeOrmModule.forFeature([User]) to provide its repository, and inject that repository with @InjectRepository(User) repo: Repository<User> — the repository is the DI-native persistence boundary, and migration files are generated from the entities. In the Prisma path, schema.prisma is the truth: prisma generate emits a fully-typed client, and because Nest has no first-class Prisma DI you wrap it yourself as a PrismaService that extends PrismaClient implements OnModuleInit and calls await this.$connect() in onModuleInit; you inject that one service and call prisma.user.findMany(), with Prisma Migrate handling schema changes — no @InjectRepository anywhere. The decision is constraint-driven: schema-as-source-of-truth with generated, non-drifting types points to Prisma; DI-native per-entity repositories point to TypeORM; raw SQL trades both away for control. And across both, synchronize: true is the production anti-pattern — it silently rewrites the live schema and drops columns — so you always use reviewed migrations instead. Now when you see a PR that flips synchronize: true “just for staging,” you know exactly what to push back on and why.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.