End-to-end coding standards for building, testing, and maintaining TypeScript Node.js applications that rely on ORMs such as Prisma, TypeORM, and MikroORM.
You're tired of debugging ORM queries that worked in development but fail in production. Tired of N+1 query surprises that tank your API performance. Tired of migration conflicts that break your deployment pipeline.
These comprehensive ORM rules transform how you build Node.js backends—turning database interactions from a source of bugs into a competitive advantage.
Every Node.js developer knows the pain:
user.posts lookup becomes 47 database queriesYou chose an ORM to move faster, but instead you're spending hours debugging database issues that shouldn't exist.
These rules establish battle-tested patterns for Prisma, TypeORM, and MikroORM that eliminate common pitfalls before they happen. Instead of learning through production outages, you get proven patterns that work at scale.
Core Philosophy: Treat your ORM as a database interface, not a magic abstraction. Model your domain first, then map it cleanly to your database.
// Instead of this performance trap:
const users = await userRepo.findAll();
for (const user of users) {
console.log(user.posts); // Hidden N+1 query
}
// You get explicit, predictable queries:
const users = await userRepo.findAll({
include: { posts: true }
});
// Raw ORM classes never escape your data layer:
interface UserDTO {
id: string;
email: string;
lastLoginAt: Date | null;
}
// Your services work with clean, domain-focused types
async function getUserProfile(id: string): Promise<UserDTO> {
return userRepo.findById(id);
}
// Transform cryptic ORM errors into actionable domain errors:
try {
await userRepo.create(userData);
} catch (error) {
if (isPrismaError(error, 'P2002')) {
throw new UniqueConstraintError('Email already exists');
}
throw new DataLayerError('Failed to create user', { cause: error });
}
// This innocent-looking code creates a performance disaster:
app.get('/api/users', async (req, res) => {
const users = await db.user.findMany();
const enriched = await Promise.all(
users.map(async (user) => ({
...user,
postCount: await db.post.count({ where: { userId: user.id } }),
lastPost: await db.post.findFirst({
where: { userId: user.id },
orderBy: { createdAt: 'desc' }
})
}))
);
res.json(enriched);
});
// Clean, predictable, and fast:
app.get('/api/users', async (req, res) => {
const users = await userRepo.findAllWithStats();
res.json(users);
});
// In your repository:
async findAllWithStats(): Promise<UserWithStatsDTO[]> {
return this.db.user.findMany({
select: {
id: true,
email: true,
name: true,
_count: { select: { posts: true } },
posts: {
take: 1,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, createdAt: true }
}
}
});
}
// Strict forward-only migrations with semantic versioning:
// V2_3_0__JIRA-1234_add_user_status.sql
// Every migration includes:
// 1. Semantic version prefix
// 2. Ticket reference
// 3. Descriptive name
// 4. SQL linting in CI
// 5. Rollback strategy documentation
// Fast unit tests with mocked ORM:
describe('UserService', () => {
it('should create user with valid email', async () => {
const mockRepo = {
create: jest.fn().mockResolvedValue(mockUser),
findByEmail: jest.fn().mockResolvedValue(null)
};
const service = new UserService(mockRepo);
const result = await service.createUser(validUserData);
expect(result.email).toBe(validUserData.email);
});
});
// Rock-solid integration tests:
describe('UserRepo Integration', () => {
beforeEach(async () => {
await testDb.migrate.latest();
await testDb.raw('TRUNCATE users CASCADE');
});
it('should enforce unique email constraint', async () => {
await userRepo.create({ email: '[email protected]' });
await expect(
userRepo.create({ email: '[email protected]' })
).rejects.toThrow(UniqueConstraintError);
});
});
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}
// src/modules/user/user.entity.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
// src/modules/user/user.repo.ts
export class UserRepo {
constructor(private readonly db: PrismaClient) {}
async findByEmail(email: string): Promise<User | null> {
if (!email) throw new InvalidInputError('Email is required');
return this.db.user.findUnique({
where: { email },
select: userSelect
});
}
async create(userData: CreateUserData): Promise<User> {
try {
return await this.db.user.create({
data: userData,
select: userSelect
});
} catch (error) {
if (isPrismaError(error, 'P2002')) {
throw new UniqueConstraintError('Email already exists');
}
throw new DataLayerError('Failed to create user', { cause: error });
}
}
}
const userSelect = {
id: true,
email: true,
name: true,
createdAt: true
} as const;
// src/errors/data-layer.error.ts
export class DataLayerError extends Error {
constructor(
message: string,
public readonly context: Record<string, unknown> = {}
) {
super(message);
this.name = 'DataLayerError';
}
}
export class UniqueConstraintError extends DataLayerError {
constructor(message: string) {
super(message);
this.name = 'UniqueConstraintError';
}
}
// src/utils/prisma-errors.ts
export function isPrismaError(error: unknown, code: string): boolean {
return error instanceof Error &&
'code' in error &&
error.code === code;
}
// src/middleware/query-logger.ts
export function createQueryLogger(db: PrismaClient) {
db.$use(async (params, next) => {
const start = Date.now();
const result = await next(params);
const duration = Date.now() - start;
if (duration > 200) {
logger.warn('Slow query detected', {
model: params.model,
action: params.action,
duration,
// Never log actual parameters in production
paramCount: Object.keys(params.args || {}).length
});
}
return result;
});
}
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
export let testDb: PrismaClient;
beforeAll(async () => {
testDb = new PrismaClient({
datasources: {
db: { url: process.env.TEST_DATABASE_URL }
}
});
await testDb.$connect();
await testDb.$executeRawUnsafe('TRUNCATE TABLE users CASCADE');
});
afterAll(async () => {
await testDb.$disconnect();
});
These rules aren't just configuration—they're a complete methodology for building production-ready Node.js applications with ORMs. You'll eliminate the common pitfalls that plague most backend teams and build APIs that scale confidently.
Stop fighting your ORM. Start building with patterns that actually work.
Implementation takes 15 minutes. The productivity gains last forever.
You are an expert in Node.js, TypeScript, SQL (PostgreSQL / MySQL / SQLite), and modern ORMs (Prisma, TypeORM, MikroORM).
Key Principles
- Model the database around the business domain first, then map entities one-to-one with clear, meaningful names.
- Keep entity classes thin: hold only data representation and simple convenience helpers—move domain logic to service layer.
- Default to immutability: treat entity instances as readonly after creation unless a transaction is open.
- Prefer explicitness over magic: always declare selected columns/relations rather than `*` or wild card includes.
- Design for testability: ensure repositories/services can be swapped with in-memory adapters.
- Fail fast: validate inputs early, throw typed errors, and exit on incongruent state before touching the DB.
TypeScript
- Enable `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `skipLibCheck` in `tsconfig.json`.
- Use `interface` for DTOs and `type` for complex unions; never expose raw ORM classes outside the data layer.
- Enforce `PascalCase` for class names (`UserAccount`), `camelCase` for properties (`lastLoginAt`), `SCREAMING_SNAKE_CASE` for env vars.
- Repository file layout per entity:
src/
modules/
user/
user.entity.ts
user.repo.ts
user.service.ts
user.controller.ts
user.test.ts
- Never access `process.env` directly in repositories; instead inject a strongly-typed config object.
Error Handling and Validation
- Wrap every multi-statement DB change in a transaction. Re-throw as `DataLayerError` with contextual metadata but no sensitive details.
- Guard clauses at top of public repo/service methods:
```ts
if (!criteria.id) throw new InvalidInputError('Missing id');
```
- Map ORM-specific errors to canonical domain errors (e.g., Prisma `P2002` -> `UniqueConstraintError`).
- Always validate external IDs with UUID regex or ULID parser before querying to avoid full-table scans.
Prisma
- Generate the client once per app lifetime; reuse via dependency-injection container.
- Use `select` or `include` explicitly—never return entire records in production queries.
- Prefer `raw` only for bulk/batched writes and always wrap with `sql`` tag to auto-escape.
- Keep `schema.prisma` canonical: migrations can only be created via `prisma migrate`; disallow manual SQL patches.
- Example repository pattern:
```ts
export class UserRepo {
constructor(private readonly db: PrismaClient) {}
async findByEmail(email: Email): Promise<User | null> {
return this.db.user.findUnique({ where: { email: email.value }, select: userSelect });
}
}
```
TypeORM
- Use `DataSource` instance injection; don’t call `getRepository` globally.
- Always specify `relations` in `find*` queries; disable `lazy: true` to avoid hidden N+1.
- Prefer `QueryBuilder` for complex filters; chain expressions rather than string fragments to keep type safety.
- Use `enum` column type only with explicit `enumName` to prevent PG from generating random names on migrations.
MikroORM
- Enable the Identity Map (`context:` option) to prevent duplication during long-running transactions.
- Use `populate` with paths array, never `*`; prefer `eager` only for truly ubiquitous relations.
- Flush strategy: call `em.flush()` manually in service layer; disable automatic flush to control transactional boundaries.
Additional Sections
Testing
- Unit: mock ORM client using `ts-auto-mock` or in-memory adapter; aim for >90% branch coverage in repositories.
- Integration: spin up per-test PostgreSQL via Testcontainers; run `migrate latest` before suite starts and `truncate cascade` after each test.
Performance
- Detect N+1 at PR time using `eslint-plugin-sonarjs` + custom rule that bans unpopulated lazy relations.
- Cache read-heavy queries with `node-cache` or Redis. Adopt cache-aside pattern with TTL ≤ 5 minutes for user-visible data.
- Batch writes: prefer `createMany` / `insertBulk` and fall back to raw `COPY` for >10k rows.
Migrations & Schema Management
- Maintain strict forward-only migrations; never edit a previously merged migration file.
- Tag each migration with semver prefix and JIRA ticket: `V2_3_0__JIRA-1234_add_user_status.sql`.
- Run linter (`sqlfluff`) on raw migration SQL in CI.
Security
- Always use parameterized queries or ORM placeholders—no string interpolation.
- Disallow dynamic table names from user input.
- Store connection strings in a secrets manager, inject via environment at runtime only.
Logging & Observability
- Log every query longer than 200 ms with parameters masked; use structured JSON logs with `query`, `duration`, and `rows` fields.
- Expose a `/healthz` endpoint that checks DB connectivity via lightweight `select 1`.
Edge Cases & Gotchas
- `Date` handling: store timestamps in UTC; convert at edge/client only.
- Large `IN` clauses: switch to temp table + join for >1000 ids.
- Sequence gaps: never rely on auto-increment ids for ordering; add `created_at` + `created_seq` composite index.
Directory Naming
- All folders lowercase-kebab: `modules/user`, `migrations/2024-06-01`.
Happy Coding!