TypeORM and read-only entities
TypeORM and read-only entities
TypeORM was once a representative ORM in the Node camp and was widely used alongside NestJS. Its position has shifted somewhat as new ORMs appeared, but it still runs in many codebases.
1. About TypeORM
TypeORM is reportedly first released around 2016 as a TypeScript/JavaScript ORM. It supports a wide range of RDBs — PostgreSQL, MySQL, MariaDB, SQLite, MS SQL Server, Oracle — and some NoSQL (MongoDB).
| Library | First appeared | Character |
|---|---|---|
| TypeORM | 2016 | Decorator based. Supports both AR and DM. |
| Sequelize | 2010 | The longest-running Node ORM. JS first. |
| MikroORM | 2018 | TS first. Identity Map · Unit of Work. |
| Prisma | 2019 (Prisma 1) → 2020 (current Prisma) | Schema file + generated client. |
| Drizzle ORM | 2022 | TS schema code. SQL-like builder. |
| Kysely | 2022 | Type-safe query builder (strictly not an ORM). |
2. Active Record vs Data Mapper
Two patterns organized in Martin Fowler's Patterns of Enterprise Application Architecture (2002).
- Active Record — the entity object saves itself.
user.save(). - Data Mapper — a separate mapper or repository persists the entity.
repo.save(user).
TypeORM provides both.
// Active Record
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn() id!: number
@Column() email!: string
}
const u = User.create({ email: 'a@b' })
await u.save()
// Data Mapper
const repo = dataSource.getRepository(User)
await repo.save(repo.create({ email: 'a@b' }))
In environments that emphasize service-layer separation and testability, Data Mapper is often the choice.
3. Entity → read-only SSOT pattern
TypeORM can synchronize the schema from entity metadata (synchronize: true) or generate migrations (migration:generate). In production, two directions commonly emerge.
① TypeORM is the truth of the schema — entities are SSOT, migrations auto-generated
② SQL is the truth of the schema — entities are mappings only, schema changes happen in SQL files
In case (2), entity decorators express only column names, types, and relations, and synchronize: true is never enabled. Production DB changes are made only via separate SQL or a migration tool. Strengths:
- DBAs and data teams can work directly in SQL.
- It blocks dangerous auto-generated migration changes (column drops, type changes).
- When the same DB is shared across languages or services, the source of truth lives in one place.
The downside is that entities can drift from the actual DB. A procedure of integration tests verifying both sides match is needed.
4. Relations and N+1
@Entity()
class Order {
@ManyToOne(() => User) user!: User
}
const orders = await repo.find()
for (const o of orders) {
console.log(o.user.email) // lazy load: SELECT each time
}
This pattern is the classic N+1 problem. One main query + N auxiliary queries. The fix is explicit join.
const orders = await repo.find({ relations: { user: true } })
// or query builder
const orders = await repo.createQueryBuilder('o')
.leftJoinAndSelect('o.user', 'u')
.getMany()
Or introduce a batch loader pattern like DataLoader (common in GraphQL environments).
5. Comparison with Prisma · Drizzle · MikroORM
Prisma — schema.prisma is the SSOT, with a generated client. Type inference is strong and migrations are well organized. The runtime used to go through a query engine (a Rust binary), and recent direction has been to slim the engine.
Drizzle — an SQL-like builder. Schema lives in TS code. Runtime overhead is small and type inference is strong.
MikroORM — implements enterprise patterns like Identity Map and Unit of Work faithfully. Tracks entity changes and flushes at once.
Sequelize — stable due to its age, but type inference is reportedly weaker than the three above.
The choice depends on the team's SQL familiarity, migration approach, and required level of type safety.
6. DataSource and Repository
DataSource is an object holding the connection pool and metadata. It is initialized once at app start and referenced globally.
export const ds = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Order],
migrations: ['migrations/*.ts'],
synchronize: false, // false in production
})
await ds.initialize()
const userRepo = ds.getRepository(User)
class UserService {
constructor(private repo = userRepo) {}
byEmail(email: string) { return this.repo.findOne({ where: { email } }) }
}
Both keeping repository methods inside services and extracting them to separate classes are valid.
7. Common pitfalls
Applying synchronize: true to production — columns can vanish. Always off in production.
Missing @JoinColumn — in ManyToOne relations, the foreign key column name can drift from intent. Specifying it is recommended.
Circular imports — bidirectional relations between entities can be made lazy with a function reference (() => Other) to avoid cycles.
Timezone columns — accurately reflect the difference between PostgreSQL's timestamptz and timestamp in the entity decorators. Misalignment causes time drift.
Transaction boundaries — to wrap several repositories in a single transaction, use a manager-based repository inside dataSource.transaction(async (manager) => ...).
Closing thoughts
ORMs are a tradeoff between the value of abstraction and the clarity of raw SQL. A small team typically lowers operational burden by starting with raw SQL, while bigger teams or richer domains start to see the abstraction gain. The "entities map only, schema in SQL" pattern sits close to the middle ground.
Next
- crawler-ethics
- openapi-spec
See TypeORM · TypeORM GitHub · Patterns of Enterprise Application Architecture · Prisma · Drizzle ORM · MikroORM · DataLoader.