Was ist TypeORM?
TypeORM ist ein Object-Relational Mapper für TypeScript und JavaScript. Es ermöglicht Ihnen, Ihre Datenbank als eine Menge von Klassen zu beschreiben, die mit Metadaten versehen sind, und diese anschließend über Repositories oder einen Fluent Builder abzufragen. TypeORM existiert bereits seit 2016 und ist das ORM, das am stärksten mit NestJS assoziiert wird.
Das besondere Merkmal ist, dass es zwei bekannte Patterns gleichzeitig unterstützt. Bei Active Record weiß eine Entity, wie sie sich selbst laden und speichern kann. Bei Data Mapper sind Entities einfache Objekte und ein separates Repository ist für die Persistenz zuständig. Die meisten Teams nutzen Data Mapper für den Anwendungscode und gelegentlich Active Record für kleine, in sich geschlossene Modelle.
Wenn Sie bereits SQL beherrschen, lässt sich TypeORM am besten als Mapping-Layer verstehen: Entities werden zu Tabellen, Decorators zu Spaltendefinitionen und jede Abfrage wird letztendlich zu SQL, das Sie auch händisch hätten schreiben können.
Entities: Klassen, die zu Tabellen werden
Eine Entity ist eine Klasse, die mit @Entity() markiert ist. Jede Instanz entspricht einer Zeile und jede dekorierte Eigenschaft einer Spalte.
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity("users")
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ unique: true })
email!: string;
@Column({ name: "display_name" })
displayName!: string;
}
Das Argument für @Entity ist der Tabellenname. Wenn Sie dieses weglassen, leitet TypeORM einen Namen aus dem Klassennamen ab; eine explizite Angabe vermeidet jedoch Überraschungen bei einem Refactoring der Namen. Die name-Option in @Column entkoppelt die Datenbankspalte von der TypeScript-Eigenschaft, wodurch displayName auf display_name gemappt wird.
Das ! nach jeder Eigenschaft ist die sogenannte Definite Assignment Assertion. TypeScript kann nicht erkennen, dass das ORM diese Felder zur Laufzeit befüllt, daher weist ! den Compiler an, der Datenbank zu vertrauen.
Spalten-Dekoratoren, die Sie tatsächlich verwenden werden
TypeORM leitet den Spaltentyp aus dem TypeScript-Typ der Eigenschaft ab, aber diese Inferenz ist begrenzt. Ein string könnte text, varchar oder char sein; für präzise Definitionen sollten Sie den Typ daher explizit übergeben.
@Entity("posts")
export class Post {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: "text" })
body!: string;
@Column({ type: "integer", default: 0 })
views!: number;
@Column({ type: "boolean", default: false })
published!: boolean;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt!: Date;
}
Die generated-column-Dekoratoren sind es wert, auswendig gelernt zu werden: @CreateDateColumn setzt einen Zeitstempel beim Einfügen, @UpdateDateColumn aktualisiert diesen bei jedem Update und @DeleteDateColumn ermöglicht Soft Deletes. @PrimaryGeneratedColumn("uuid") erzeugt eine UUID anstelle einer automatisch inkrementierten Ganzzahl, was nützlich ist, wenn IDs öffentlich sichtbar sind.
Speichern Sie Geldbeträge als Ganzzahlen in kleinsten Einheiten in einer integer- oder bigint-Spalte und niemals als Float. Für wirklich optionale Attribute ist eine jsonb-Spalte mit { type: "jsonb" } in Ordnung, aber alles, wonach Sie filtern oder was Sie einschränken möchten, gehört in eine eigene Spalte.
Die DataSource und Konfiguration
Seit Version 0.3 ist eine DataSource das zentrale Objekt, das die Verbindungsoptionen verwaltet und Repositories, Manager sowie Transaktionen bereitstellt.
import "reflect-metadata";
import { DataSource } from "typeorm";
export const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
entities: ["src/entities/*.entity.ts"],
migrations: ["src/migrations/*.ts"],
synchronize: false,
logging: ["error", "warn"],
});
Der reflect-metadata-Import muss erfolgen, bevor eine Entity geladen wird, da die Decorators darauf angewiesen sind. Verweisen Sie in einem kompilierten Build bei entities und migrations auf die generierten .js-Dateien anstelle der .ts-Quelldateien.
Deaktivieren Sie synchronize überall, außer bei einer temporären lokalen Datenbank. Diese Option schreibt das Schema beim Start so um, dass es Ihren Entities entspricht. Das bedeutet, dass eine umbenannte Eigenschaft stillschweigend eine Spalte und deren Daten löschen kann. Migrationen existieren genau deshalb, damit Schemaänderungen überprüft, versioniert und reproduzierbar sind.
Repositories und die find API
Ein Repository ist ein typisiertes Gateway zu einer einzelnen Entität. Du erhältst eines vom DataSource und nutzt es für den Großteil deiner Lese- und Schreibzugriffe.
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
const recent = await users.find({
where: { email: ILike("%@example.com") },
order: { createdAt: "DESC" },
take: 20,
skip: 0,
});
find gibt ein Array zurück und findOne gibt eine einzelne Zeile oder null zurück. Das Options-Objekt akzeptiert where, order, take, skip, select, relations und withDeleted. Operatoren wie ILike, In, MoreThan und IsNull befinden sich im typeorm-Package und werden innerhalb von where zusammengesetzt.
Schreibvorgänge nutzen create, save und remove:
const user = users.create({ email, displayName });
await users.save(user);
user.displayName = "Ada Lovelace";
await users.save(user);
save führt einen Upsert durch: Es fügt einen Datensatz ein, wenn der Primary Key fehlt, und aktualisiert ihn, wenn er vorhanden ist. Diese Komfortfunktion verbirgt, ob eine Zeile erstellt oder geändert wurde. Wenn diese Unterscheidung wichtig ist, verwende insert und update direkt.
Relationen und deren Laden
Relationen werden auf beiden Seiten definiert. Die @ManyToOne-Seite besitzt den Foreign Key, und @OneToMany ist die entsprechende Inverse.
@Entity("posts")
export class Post {
@ManyToOne(() => User, (user) => user.posts, { onDelete: "CASCADE" })
@JoinColumn({ name: "author_id" })
author!: User;
@Column({ name: "author_id" })
authorId!: string;
}
@Entity("users")
export class User {
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}
Es ist ein gängiges und nützliches Pattern, eine explizite authorId-Spalte neben der Relation zu führen: So können Sie den Foreign Key lesen und danach filtern, ohne die verknüpfte Zeile laden zu müssen.
@ManyToMany benötigt eine Join-Tabelle, die auf der besitzenden Seite mit @JoinTable definiert wird. @OneToOne funktioniert wie @ManyToOne, erzwingt jedoch die Eindeutigkeit.
Das Laden ist der entscheidende Teil. Standardmäßig werden Relationen nicht geladen. Sie müssen dies über die relations-Option, ein Eager-Flag oder einen Join im QueryBuilder anfordern.
const posts = await AppDataSource.getRepository(Post).find({
relations: { author: true },
where: { published: true },
});
Eager-Relationen werden bei jedem find automatisch geladen, während Lazy-Relationen ein Promise zurückgeben, das bei Zugriff eine Abfrage auslöst. Beides ist komfortabel, verbirgt jedoch die Anzahl der ausgeführten Queries. Bevorzugen Sie das explizite Laden, insbesondere in performance-kritischen Bereichen (Hot Paths), damit die Kosten einer Abfrage dort sichtbar sind, wo sie geschrieben wird.
Der QueryBuilder für echte Abfragen
Der QueryBuilder deckt Joins, Aggregate und dynamische Filter ab, die über die find API nicht sauber ausgedrückt werden können.
const posts = await AppDataSource.getRepository(Post)
.createQueryBuilder("post")
.innerJoinAndSelect("post.author", "author")
.where("author.email = :email", { email })
.andWhere("post.createdAt >= :since", { since })
.orderBy("post.createdAt", "DESC")
.take(20)
.getMany();
Aliase sind obligatorisch und Parameter werden immer mit :name-Platzhaltern gebunden, niemals per String-Interpolation. getMany gibt Entities zurück, getRawMany gibt einfache Zeilen (plain rows) zurück, und getOne oder getManyAndCount decken die gängigen Fälle für Einzelzeilen und Pagination ab.
Dynamische Filter lassen sich natürlich aufbauen:
const qb = repo.createQueryBuilder("post").where("post.published = true");
if (tag) {
qb.andWhere("post.tags @> :tag", { tag: [tag] });
}
if (search) {
qb.andWhere("post.title ILIKE :search", { search: `%${search}%` });
}
Wenn eine Abfrage zu spezialisiert für den Builder ist, führt dataSource.query() parametrisiertes SQL direkt aus und gibt Raw Rows zurück.
Migrationen: Generieren und Ausführen
Migrationen sind der einzige sichere Weg, um ein Production-Schema zu ändern. TypeORM kann diese generieren, indem die Entities mit der Live-Datenbank verglichen werden.
npx typeorm migration:generate src/migrations/CreateUsers -d src/data-source.ts
npx typeorm migration:run -d src/data-source.ts
npx typeorm migration:revert -d src/data-source.ts
Die generierte Datei enthält die Methoden up und down. Lesen Sie diese vor dem Commit sorgfältig durch: Der Diff ist eine bestmögliche Schätzung und erzeugt gelegentlich destruktive Statements, die so nicht beabsichtigt waren.
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateUsers1710000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE users`);
}
}
Führen Sie Migrationen als separaten Deploy-Schritt mit einer Rolle aus, die DDL ausführen darf – getrennt von der Rolle, die die Anwendung zur Laufzeit verwendet. Lassen Sie die Anwendung niemals ihr eigenes Schema beim Booten ändern.
Transaktionen
Eine Transaktion gruppiert Statements, sodass entweder alle erfolgreich abgeschlossen werden oder alle zurückgerollt (Rollback) werden. Der DataSource.transaction-Helper übernimmt das Commit, den Rollback und die Verbindung für Sie.
await AppDataSource.transaction(async (manager) => {
const user = await manager.save(User, { email, displayName });
await manager.save(Post, { title, body, author: user });
});
Wenn der Callback einen Fehler wirft, führt TypeORM einen Rollback durch und wirft den Fehler erneut. Verwenden Sie für jedes Statement innerhalb des Blocks den bereitgestellten manager; ein Repository, das direkt über den DataSource bezogen wurde, nutzt eine andere Verbindung und würde außerhalb der Transaktion ausgeführt werden.
Für eine präzisere Steuerung bietet createQueryRunner Zugriff auf startTransaction, commitTransaction und rollbackTransaction. Dies ist nützlich, wenn die Transaktion Code umfasst, der nicht als ein einziger Callback ausgedrückt werden kann. Halten Sie solche Transaktionen jedoch kurz: Eine offene Transaktion über einen HTTP-Aufruf hinweg blockiert den Vacuum-Prozess und führt zu Lock Contention.
Das N+1-Problem vermeiden
Das N+1-Problem ist der häufigste Performance-Bug in ORM-Code. Man lädt eine Liste von Posts, iteriert dann darüber und greift auf post.author zu, was eine Abfrage für die Liste plus eine Abfrage pro Post erzeugt.
// N+1: one query for posts, then one per author.
const posts = await repo.find();
for (const post of posts) {
console.log(post.author.email);
}
Die Lösung besteht darin, die Relation vorab in einer einzigen Abfrage zu laden:
const posts = await repo.find({ relations: { author: true } });
Wenn Sie nur eine Anzahl benötigen, vermeiden Sie es komplett, die Child-Objekte zu laden:
const users = await AppDataSource.getRepository(User)
.createQueryBuilder("user")
.loadRelationCountAndMap("user.postCount", "user.posts")
.getMany();
Aktivieren Sie im Development-Modus das Query-Logging und lesen Sie das Log nach dem Rendern einer Seite. Wenn ein Request Dutzende von nahezu identischen Abfragen auslöst, liegt ein N+1-Problem vor – die relations-Option oder ein Join ist hier die Lösung.
TypeORM in NestJS
@nestjs/typeorm integriert das ORM in die Dependency Injection von Nest. Das Root-Modul verwaltet die Verbindung, während Feature-Module die entsprechenden Repositories anfordern.
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false,
}),
TypeOrmModule.forFeature([User, Post]),
],
})
export class AppModule {}
Ein Service erhält sein Repository anschließend über den Konstruktor:
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findByEmail(email: string) {
return this.users.findOneBy({ email });
}
}
autoLoadEntities registriert Entities, die an forFeature übergeben wurden, wodurch die Root-Konfiguration frei von langen Import-Listen bleibt. Führen Sie Migrationen über die Nest CLI oder ein eigenständiges Skript aus, anstatt sie direkt beim Anwendungsstart zu starten.
Active Record oder Data Mapper?
Bei der Entscheidung geht es primär darum, wo die Persistenz angesiedelt ist. Active Record ist bei einfachen Modellen kürzer, da die Entity ihre eigenen Query-Methoden mitführt.
@Entity("users")
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
email!: string;
}
const user = await User.findOneBy({ id });
Data Mapper hingegen behandelt Entities als reine Datenobjekte und verschiebt die gesamte Persistenz in Repositories:
const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });
Active Record ist in Skripten und kleinen Services praktisch, koppelt das Modell jedoch stark an das ORM und erschwert das Testen. Data Mapper ist die bessere Standardwahl für Anwendungscode und wird auch von NestJS empfohlen. Entscheiden Sie sich pro Projekt für einen Stil, anstatt beide zu mischen, da Konsistenz wichtiger ist als die spezifische Wahl.
Best Practices
- Lassen Sie
synchronizedeaktiviert und verwalten Sie jede Schema-Änderung über eine geprüfte Migration. - Laden Sie Relationen explizit mit
relationsoder einem QueryBuilder-Join und protokollieren Sie Queries in der Entwicklungsumgebung. - Verwenden Sie
@Column({ type: ... })für alles, bei dem der abgeleitete Typ falsch sein könnte. - Behalten Sie die
authorIdForeign-Key-Spalte neben der Relation für effizientes Filtern bei. - Bevorzugen Sie Data Mapper Repositories für den Anwendungscode und reservieren Sie Active Record für Skripte.
- Verwenden Sie
dataSource.transactionund übergeben Sie die Transactionmanagerimmer an verschachtelte Aufrufe. - Binden Sie jeden Wert als Parameter ein; interpolieren Sie niemals Benutzereingaben direkt in SQL.
- Fügen Sie Indizes für die Spalten hinzu, über die Sie filtern oder Joins durchführen, und bestätigen Sie diese mit
EXPLAIN ANALYZE. - Führen Sie Migrationen mit einer dedizierten DDL-Rolle aus, die von der Runtime-Rolle der Anwendung getrennt ist.
Häufige Fehler
synchronize: trueauf einer gemeinsam genutzten Datenbank oder einer Produktionsdatenbank aktiviert lassen.- Zugriff auf eine lazy relation innerhalb einer Schleife, was zu einem N+1 Query-Storm führt.
- Den
reflect-metadata-Import vergessen, wodurch die Decorator-Metadaten zur Laufzeit nicht mehr funktionieren. - Verwendung des
DataSource-Repositorys innerhalb einer Transaction anstelle des bereitgestellten Managers. - Die Annahme, dass die generierte Migration immer sicher ist; beim Umbenennen können Spalten gelöscht werden.
- Eine Spalte als
stringtypisieren und TypeORM unerwartetvarchar(255)inferieren lassen. - Geldbeträge als Float speichern und dadurch Präzision verlieren.
findOneso behandeln, als würde es einen Fehler werfen; es gibtnullzurück, wenn keine Übereinstimmung gefunden wird.- Vermischung von Active Record und Data Mapper Patterns innerhalb derselben Entity.
Wie geht es weiter?
TypeORM ist eine komfortable Wahl, wenn Sie mit NestJS arbeiten, und ein grundlegendes Verständnis hilft dabei, Alternativen besser bewerten zu können. Lesen Sie den Prisma-Guide für ein Schema-First ORM mit einem generierten Client oder schauen Sie sich Drizzle ORM an, wenn Sie eine schlanke Schicht bevorzugen, bei der SQL im Vordergrund steht. Um tiefer in die Datenbank selbst einzutauchen, behandelt der PostgreSQL-Guide Indizes, Transaktionen und den Planner, während NestJS zeigt, wie Sie die Services strukturieren, die Ihre Repositories umschließen.