TypeORM Entities and Migrations (2026)
Claude Code TypeORM Entities Relations Migration Workflow
Building solid database layers with TypeORM requires careful attention to entity design, relationship mapping, and migration management. This guide walks you through a practical workflow using Claude Code to accelerate TypeORM development while maintaining code quality and database integrity. Each section includes production-ready patterns you can adapt directly to your NestJS, Express, or standalone TypeScript projects.
Setting Up Your TypeORM Project
Before diving into entities, ensure your TypeORM project is properly configured. Claude Code can help scaffold the initial setup quickly, but the configuration deserves careful thought. The DataSource configuration is the foundation everything else depends on:
import { DataSource } from "typeorm";
import { User } from "./entities/User";
import { Product } from "./entities/Product";
import { Order } from "./entities/Order";
import { OrderItem } from "./entities/OrderItem";
import { Category } from "./entities/Category";
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5432"),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [User, Product, Order, OrderItem, Category],
migrations: ["src/migrations/*.ts"],
logging: process.env.NODE_ENV === "development",
synchronize: false,
});
Always set synchronize: false in production environments. Relying on automatic synchronization can lead to unintended schema changes and data loss. The synchronize: true shortcut is fine for rapid prototyping, but the moment your data matters. switch it off and use migrations.
A useful practice is to keep a CLAUDE.md at your project root so Claude Code carries context across sessions:
TypeORM Project Context
Stack
- TypeORM 0.3.x
- PostgreSQL 16
- NestJS 10
- Node 20 / TypeScript 5.3
Conventions
- All primary keys are UUIDs
- All entities extend BaseEntity (src/entities/BaseEntity.ts)
- Migrations live in src/migrations/
- Do not use synchronize in production. always generate migrations
- Foreign key columns are explicit (e.g. userId: string alongside user: User)
- Use soft deletes (deletedAt) rather than hard deletes for user data
Creating TypeORM Entities
Entities are the foundation of your database layer. Each entity maps to a database table, and properties map to columns. Rather than duplicating audit columns on every entity, define a shared base entity that all others extend:
// src/entities/BaseEntity.ts
import {
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
} from "typeorm";
export abstract class BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn()
deletedAt: Date | null;
}
Now every entity automatically gets id, createdAt, updatedAt, and soft-delete support via deletedAt. Here is a complete User entity using the base:
// src/entities/User.ts
import {
Entity,
Column,
OneToMany,
OneToOne,
Index,
} from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { Order } from "./Order";
import { Profile } from "./Profile";
export enum UserRole {
USER = "user",
ADMIN = "admin",
MODERATOR = "moderator",
}
@Entity("users")
export class User extends BaseEntity {
@Index()
@Column({ unique: true })
email: string;
@Column({ select: false }) // never returned by default queries
passwordHash: string;
@Column({ nullable: true, length: 100 })
firstName: string;
@Column({ nullable: true, length: 100 })
lastName: string;
@Column({ type: "enum", enum: UserRole, default: UserRole.USER })
role: UserRole;
@Column({ default: false })
isActive: boolean;
@OneToOne(() => Profile, (profile) => profile.user, { cascade: ["insert", "update"] })
profile: Profile;
@OneToMany(() => Order, (order) => order.user)
orders: Order[];
}
Notice several production details here: select: false on passwordHash means it never leaks into API responses unless explicitly requested; the @Index() on email speeds up lookups; the enum is string-based rather than integer-based for readable query logs and easier debugging.
Defining Entity Relationships
TypeORM supports four relationship types. Choosing the wrong one is a common source of bugs and performance problems. Here is a quick decision guide:
| Scenario | Decorator | Foreign key lives on |
|---|---|---|
| Profile belongs to one User | OneToOne | profiles table |
| User has many Orders | OneToMany + ManyToOne | orders table |
| Order contains many Products | ManyToMany | join table |
| OrderItem has one Product | ManyToOne | order_items table |
One-to-One Relationship
A User has exactly one Profile. The foreign key lives on the Profile side:
// src/entities/Profile.ts
import { Entity, Column, OneToOne, JoinColumn } from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { User } from "./User";
@Entity("profiles")
export class Profile extends BaseEntity {
@Column({ nullable: true })
bio: string;
@Column({ nullable: true })
avatarUrl: string;
@Column({ nullable: true })
website: string;
@OneToOne(() => User, (user) => user.profile)
@JoinColumn({ name: "userId" })
user: User;
@Column()
userId: string;
}
Always include the explicit foreign key column (userId: string) alongside the relation property. This makes it possible to update the relation without loading the related entity, and it prevents accidental lazy-load queries.
One-to-Many Relationship
A User can have multiple Orders. The @JoinColumn decorator specifies which column represents the foreign key on the many side:
// src/entities/Order.ts
import {
Entity,
Column,
ManyToOne,
OneToMany,
JoinColumn,
} from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { User } from "./User";
import { OrderItem } from "./OrderItem";
export enum OrderStatus {
PENDING = "pending",
CONFIRMED = "confirmed",
SHIPPED = "shipped",
DELIVERED = "delivered",
CANCELLED = "cancelled",
}
@Entity("orders")
export class Order extends BaseEntity {
@Column({ type: "enum", enum: OrderStatus, default: OrderStatus.PENDING })
status: OrderStatus;
@Column("decimal", { precision: 10, scale: 2 })
totalAmount: number;
@Column({ nullable: true })
shippedAt: Date;
@ManyToOne(() => User, (user) => user.orders, { onDelete: "RESTRICT" })
@JoinColumn({ name: "userId" })
user: User;
@Column()
userId: string;
@OneToMany(() => OrderItem, (item) => item.order, { cascade: ["insert"] })
items: OrderItem[];
}
Note the use of onDelete: "RESTRICT" rather than CASCADE. This prevents accidentally deleting all of a user’s order history if a User record is removed. Use RESTRICT by default and only use CASCADE when you have explicitly decided child records should be destroyed.
Many-to-Many Relationship
Products can belong to multiple Categories. Use @JoinTable only on the owning side:
// src/entities/Product.ts
import {
Entity,
Column,
ManyToMany,
OneToMany,
JoinTable,
Index,
} from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { Category } from "./Category";
import { OrderItem } from "./OrderItem";
@Entity("products")
export class Product extends BaseEntity {
@Index()
@Column({ length: 200 })
name: string;
@Column({ type: "text", nullable: true })
description: string;
@Column("decimal", { precision: 10, scale: 2 })
price: number;
@Column({ default: 0 })
stockQuantity: number;
@Column({ default: true })
isActive: boolean;
@ManyToMany(() => Category, (category) => category.products)
@JoinTable({
name: "product_categories",
joinColumn: { name: "productId", referencedColumnName: "id" },
inverseJoinColumn: { name: "categoryId", referencedColumnName: "id" },
})
categories: Category[];
@OneToMany(() => OrderItem, (item) => item.product)
orderItems: OrderItem[];
}
The Category entity on the inverse side simply declares the @ManyToMany without @JoinTable:
// src/entities/Category.ts
import { Entity, Column, ManyToMany } from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { Product } from "./Product";
@Entity("categories")
export class Category extends BaseEntity {
@Column({ unique: true, length: 100 })
name: string;
@Column({ unique: true, length: 120 })
slug: string;
@ManyToMany(() => Product, (product) => product.categories)
products: Product[];
}
The OrderItem Join Entity
For many-to-many relationships that carry extra data (like quantity and unit price at time of purchase), use an explicit join entity rather than @JoinTable:
// src/entities/OrderItem.ts
import { Entity, Column, ManyToOne, JoinColumn } from "typeorm";
import { BaseEntity } from "./BaseEntity";
import { Order } from "./Order";
import { Product } from "./Product";
@Entity("order_items")
export class OrderItem extends BaseEntity {
@ManyToOne(() => Order, (order) => order.items, { onDelete: "CASCADE" })
@JoinColumn({ name: "orderId" })
order: Order;
@Column()
orderId: string;
@ManyToOne(() => Product, (product) => product.orderItems, { onDelete: "RESTRICT" })
@JoinColumn({ name: "productId" })
product: Product;
@Column()
productId: string;
@Column({ type: "int" })
quantity: number;
// Snapshot the price at time of purchase. never join back to Product for historical prices
@Column("decimal", { precision: 10, scale: 2 })
unitPrice: number;
}
This is a critical pattern: storing unitPrice as a snapshot means historical orders remain accurate even if the product’s price changes later. Claude Code can flag this risk when you paste a schema and ask “what data integrity issues does this have?”
Handling Soft Deletes Correctly
With deletedAt on BaseEntity, TypeORM automatically filters soft-deleted records when you have the @DeleteDateColumn decorator. Verify this in your queries:
// Records with deletedAt !== null are excluded automatically
const users = await userRepository.find();
// To include soft-deleted records:
const allUsers = await userRepository.find({ withDeleted: true });
// To soft-delete:
await userRepository.softDelete(userId);
// To hard-delete (use with extreme caution):
await userRepository.delete(userId);
Be aware that softDelete does not cascade. If you soft-delete a User, their Orders are not automatically soft-deleted. You need to handle cascading soft deletes in your service layer, or accept that orphaned child records remain visible. Ask Claude to audit your service layer for this pattern: “Check all places where we soft-delete User and tell me what related records are left dangling.”
Generating and Running Migrations
Never modify your schema directly in production. Migrations give you a versioned, reversible record of every schema change. The workflow with TypeORM migrations has three steps: generate, review, apply.
Generating Migrations Automatically
TypeORM can diff your entity definitions against the current database schema and generate the migration for you:
npx typeorm-ts-node-commonjs migration:generate src/migrations/AddUserRoleEnum -d src/data-source.ts
This produces a timestamped migration file. Always read the generated file before running it. TypeORM sometimes generates destructive changes if column types have changed.
Writing Migrations Manually
For complex changes involving data backfills or multi-step operations, write the migration by hand:
// src/migrations/1700000000000-AddOrderStatusEnum.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddOrderStatusEnum1700000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create the enum type
await queryRunner.query(`
CREATE TYPE order_status_enum AS ENUM (
'pending', 'confirmed', 'shipped', 'delivered', 'cancelled'
)
`);
// Add the column using the new type
await queryRunner.query(`
ALTER TABLE orders
ADD COLUMN status order_status_enum NOT NULL DEFAULT 'pending'
`);
// Backfill from old boolean columns if they existed
await queryRunner.query(`
UPDATE orders SET status = 'confirmed' WHERE is_confirmed = true
`);
// Add index for common status-based queries
await queryRunner.query(`
CREATE INDEX idx_orders_status ON orders (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX idx_orders_status`);
await queryRunner.query(`ALTER TABLE orders DROP COLUMN status`);
await queryRunner.query(`DROP TYPE order_status_enum`);
}
}
The down method is not optional. If a deployment goes wrong and you need to roll back, a missing down method turns a 5-minute fix into an emergency.
Running and Reverting Migrations
Apply all pending migrations
npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts
Check which migrations are pending
npx typeorm-ts-node-commonjs migration:show -d src/data-source.ts
Revert the last applied migration
npx typeorm-ts-node-commonjs migration:revert -d src/data-source.ts
In a CI/CD pipeline, run migrations as a step before deploying the new application version. Never run migrations after the app is already serving traffic on the new version. some migrations are incompatible with the old application code and will cause errors during the rollout window.
Querying with the Repository Pattern
TypeORM’s repository pattern integrates naturally with Claude Code’s ability to generate complex query builders from plain English descriptions. Here is a realistic example combining eager loading, filtering, and pagination:
// src/repositories/OrderRepository.ts
import { Repository, DataSource, Between } from "typeorm";
import { Order, OrderStatus } from "../entities/Order";
export class OrderRepository {
private repo: Repository<Order>;
constructor(dataSource: DataSource) {
this.repo = dataSource.getRepository(Order);
}
async findByUserWithItems(
userId: string,
page = 1,
limit = 20
): Promise<[Order[], number]> {
return this.repo.findAndCount({
where: { userId },
relations: { items: { product: true } },
order: { createdAt: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
}
async findRevenueByDateRange(startDate: Date, endDate: Date): Promise<number> {
const result = await this.repo
.createQueryBuilder("order")
.select("SUM(order.totalAmount)", "total")
.where("order.status = :status", { status: OrderStatus.DELIVERED })
.andWhere("order.createdAt BETWEEN :start AND :end", {
start: startDate,
end: endDate,
})
.getRawOne<{ total: string }>();
return parseFloat(result?.total ?? "0");
}
}
Ask Claude Code to generate repository methods by describing them: “Write a TypeORM query that returns all orders for a user in the last 30 days, including items and products, ordered by most recent, with a count of total results for pagination.”
Best Practices for TypeORM Development
| Practice | Why it matters |
|---|---|
| Use explicit column types | TypeORM’s inference can produce unexpected types on different databases |
| Index foreign keys | Without indexes, joins and lookups on FK columns do full table scans |
| Never modify existing migrations | Other developers or environments may have already run them |
Write down methods |
Rollbacks are impossible without them |
| Use transactions for multi-entity writes | Without transactions, partial failures leave data in inconsistent states |
| Store price/name snapshots on order items | Prevents historical records from changing when source data changes |
| Use string enums over integer enums | Readable in database logs, easier to debug, safe to add values |
| Keep entities lean | Move business logic to service classes; entities are schema definitions |
For transactions wrapping multi-entity writes:
await AppDataSource.transaction(async (manager) => {
const order = manager.create(Order, { userId, status: OrderStatus.PENDING, totalAmount: 0 });
await manager.save(order);
let total = 0;
for (const item of cartItems) {
const orderItem = manager.create(OrderItem, {
orderId: order.id,
productId: item.productId,
quantity: item.quantity,
unitPrice: item.product.price,
});
await manager.save(orderItem);
total += item.quantity * item.product.price;
}
order.totalAmount = total;
await manager.save(order);
});
If any step inside the transaction callback throws, TypeORM automatically rolls back the entire operation. Without a transaction, a crash between saving the Order and saving its OrderItems leaves an empty order in the database.
Conclusion
A solid TypeORM workflow combines proper entity design, clear relationship definitions, and disciplined migration management. Claude Code can help you generate entities, write migrations, review schemas for integrity issues, and maintain consistency across your database layer. By following these patterns. shared base entities, explicit foreign key columns, string enums, soft deletes, transactional writes, and always-present down methods. you build a database layer that holds up as your application grows.
Remember: your database schema is the foundation of your application. Invest time in proper design, use migrations for all changes, and your future self will thank you. Step-by-Step Guide: Building a Type-Safe Data Layer with TypeORM
Here is a concrete approach to establishing a production-ready TypeORM workflow.
Step 1. Generate entities from your existing database. If you are migrating a legacy project, use typeorm-model-generator to reverse-engineer your existing tables into TypeORM entities. Claude Code reviews the generated entities, adds proper relationship decorators, replaces nullable constraints, and adds missing indexes the generator may have omitted.
Step 2. Set up the DataSource with environment-specific configuration. Create a dataSource.ts that reads connection parameters from environment variables and selects the appropriate logging level based on NODE_ENV. Claude Code generates the DataSource factory with TypeScript discriminated unions that prevent running synchronize: true in production.
Step 3. Write your first migration manually. For the initial database creation, write the migration by hand rather than relying on typeorm migration:generate. This teaches you the migration API and ensures you understand exactly what DDL runs against your database. Claude Code reviews the migration for correctness and checks that the down method properly reverses every up action.
Step 4. Create a migration workflow script. Add npm scripts for common migration operations: generate, run, revert, and show. Claude Code generates these scripts and documents the correct order of operations for common scenarios like adding a column to a table that already has data.
Step 5. Set up the repository pattern. Create typed repository classes that extend TypeORM’s Repository with your business-logic query methods. Claude Code generates the base repository class with common patterns. findByIdOrThrow that throws a typed NotFoundException, softDelete that sets isDeleted rather than removing the row, and paginate that returns a typed PaginationResult.
Common Pitfalls
Using synchronize: true in staging or production. TypeORM’s automatic synchronization can drop columns silently when you rename a property on an entity. Always set synchronize: false for any environment that has real data. Claude Code adds an environment guard that throws if synchronize is set to true while NODE_ENV is not development.
Not handling migrations in CI/CD correctly. Running migrations during application startup means a failed migration can crash your entire application fleet simultaneously. Instead, run migrations as a pre-deployment step in CI. Claude Code generates the separate migration runner script and the GitHub Actions job.
Circular relationship definitions causing import errors. TypeORM relationships often require circular imports between entity files. Using string references for relation targets breaks these cycles cleanly. Claude Code detects circular imports in your entity files and suggests the string reference approach.
N+1 queries from unoptimized loading. Loading a User entity and then accessing user.orders triggers a separate query for each user. Using TypeORM’s findAndCount with a properly structured eager loading option avoids this. Claude Code reviews your repository queries and flags any patterns likely to produce N+1 queries.
Not using transactions for multi-entity writes. Writing to two related tables without a transaction means a failure partway through leaves your database in an inconsistent state. Claude Code generates transaction wrappers for every service method that touches more than one table.
Best Practices
Use a base entity with common fields. Create a BaseEntity class with id, createdAt, and updatedAt that all your entities extend. This ensures consistent column types and names across your schema. Claude Code generates the BaseEntity with UUID primary key and automatic timestamp columns.
Add database-level indexes for all foreign keys. TypeORM creates foreign key constraints but does not automatically create indexes on the referencing column. Without indexes, joins and filtered queries on foreign key columns perform full table scans. Claude Code generates a migration that adds indexes for every relationship in your entities.
Test migrations against a copy of production data. Before applying a migration to production, run it against a recent database dump in your staging environment. Claude Code generates the staging migration testing script and a timing report showing how long each migration step took.
Version your DataSource configuration. Keep multiple DataSource configurations: one for the application, one for migrations, one for testing. Claude Code generates all three with the appropriate settings and the environment switching logic that selects the right configuration.
Integration Patterns
NestJS integration. Claude Code generates the TypeORM NestJS module configuration that registers your entities, provides the DataSource token for injection, and configures the connection pool size. It also generates the custom repository provider pattern that NestJS recommends for testing.
GraphQL with TypeGraphQL. For projects combining TypeORM with TypeGraphQL, Claude Code generates entities that use both ObjectType and Entity decorators on the same class, sharing the schema definition between the GraphQL layer and the database layer without duplication.
Seeding and test fixtures. Claude Code generates a seed script using TypeORM’s DataSource that creates realistic test data for each entity in dependency order. The seed script uses factories that generate realistic data and can be run in CI before integration tests.
Related Reading
- Claude Code Data Retention Policy Workflow
- Claude Code for Aurora Serverless V2 Workflow
- Claude Code for Branch Protection Rules Workflow
- Claude Code Tech Lead Cross-Team Alignment Workflow Tips
- Claude Code Business Intelligence Workflow
- Claude Code for Gravitee API Gateway Workflow
- Claude Code for PyTorch LoRA Fine-Tuning Workflow
- Claude Code for Engineering Wiki Workflow Tutorial
- Claude Code for Fresh Deno Framework Workflow
- Claude Code Docker Compose API Tutorial Guide
- Claude Code Nx Generators — Complete Developer Guide
Built by theluckystrike. More at zovo.one
Try it: Paste your error into our Error Diagnostic for an instant fix.
Find the right skill → Browse 155+ skills in our Skill Finder.
Frequently Asked Questions
What is Setting Up Your TypeORM Project?
Setting up a TypeORM project requires configuring a DataSource with your database connection parameters (PostgreSQL 16, host, port, credentials), registering entity classes, and specifying a migrations directory. Always set synchronize: false in production to prevent unintended schema changes. A CLAUDE.md file at your project root preserves conventions like UUID primary keys, soft deletes, and migration paths across Claude Code sessions.
What is Creating TypeORM Entities?
Creating TypeORM entities means defining TypeScript classes decorated with @Entity that map to database tables. Best practice is to create an abstract BaseEntity with @PrimaryGeneratedColumn("uuid"), @CreateDateColumn, @UpdateDateColumn, and @DeleteDateColumn for soft deletes. All concrete entities extend this base, gaining consistent audit columns. Use select: false on sensitive fields like passwordHash and string-based enums for readable query logs.
What is Defining Entity Relationships?
TypeORM supports four relationship types: @OneToOne, @OneToMany, @ManyToOne, and @ManyToMany. The foreign key column lives on the “many” side or the side with @JoinColumn. Always include an explicit foreign key property (e.g., userId: string) alongside the relation property to enable updates without loading related entities. Use onDelete: "RESTRICT" by default and only use CASCADE when child records should be destroyed with the parent.
What is One-to-One Relationship?
A OneToOne relationship in TypeORM connects two entities where each record pairs with exactly one record in the other table. The @JoinColumn decorator goes on the owning side, which holds the foreign key column. For example, a Profile entity owns the relationship to User via @JoinColumn({ name: "userId" }). Always declare the explicit userId: string column alongside the relation to avoid accidental lazy-load queries.
What is One-to-Many Relationship?
A OneToMany relationship in TypeORM models cases like a User having multiple Orders. The @ManyToOne decorator on the Order entity holds the @JoinColumn with the foreign key (userId). Use onDelete: "RESTRICT" to prevent accidental deletion of a user’s order history. Cascade options like cascade: ["insert"] on the @OneToMany side allow saving child entities automatically when the parent is saved.