The Coupling Trap
The default NestJS + TypeORM pattern leads to a deceptive trap. You start by defining a User class, decorate it with @Entity(), add a few @Column() decorators, and then write methods like isSubscriptionActive() directly on that class.
It looks efficient until you try to unit test your business logic. Suddenly, you cannot instantiate a User without dragging in TypeORM's metadata storage. Your domain logic is now tightly coupled to your persistence infrastructure. If you change your database from SQL to Mongo, or just rename a column, you risk breaking business rules that shouldn't care about storage details.
This is the "Active Record" anti-pattern in complex systems: it conflates what the data is (Domain) with how the data is stored (Infrastructure).
The Root Cause: Mixing Concerns
Under the hood, TypeORM decorators modify the class prototype to register metadata. When you inject a TypeORM repository, it expects an entity that perfectly matches the database schema.
However, a Domain Entity should represent the state and behavior of a business object. It demands encapsulation, invariants (e.g., "Email cannot be null"), and domain-specific types. A Database Entity represents a row in a table. It demands scalar types (strings, numbers) and foreign keys.
When these two are the same class, you are forced to compromise. You make properties public to satisfy TypeORM hydration, breaking encapsulation. You rely on the database constraints for validation, making tests slow.
The Solution: Explicit Mapping Layers
To implement Hexagonal Architecture (Ports and Adapters), we must separate the code into three distinct buckets:
- Domain: Pure TypeScript classes containing business logic. Zero dependencies.
- Infrastructure: TypeORM entities defining the table schema.
- Adapters: Mappers and Repositories that translate between the two.
1. The Pure Domain Entity
This class has no decorators. It uses a private constructor and a static factory method to enforce validity upon creation.
// src/domain/user/user.entity.ts
export type UserProps = {
id: string;
email: string;
isActive: boolean;
createdAt: Date;
};
export class User {
// Private constructor forces use of factory methods
private constructor(private readonly props: UserProps) {}
public static create(props: UserProps): User {
// Invariants: Logic that MUST be true for a User to exist
if (!props.email.includes('@')) {
throw new Error('Invalid email format');
}
return new User(props);
}
// Business Logic Methods
public activate(): void {
this.props.isActive = true;
}
public deactivate(): void {
this.props.isActive = false;
}
// Getters (prevent mutation from outside)
get id(): string { return this.props.id; }
get email(): string { return this.props.email; }
get isActive(): boolean { return this.props.isActive; }
get createdAt(): Date { return this.props.createdAt; }
}
2. The Port (Repository Interface)
The domain defines the contract for saving data, but not the implementation.
// src/domain/user/user.repository.port.ts
import { User } from './user.entity';
export const USER_REPOSITORY = 'USER_REPOSITORY';
export interface UserRepositoryPort {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
}
3. The Infrastructure Entity (TypeORM)
This is a dumb data container. It exists solely to tell TypeORM how to map data to the SQL table.
// src/infrastructure/persistence/entities/user.orm-entity.ts
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity({ name: 'users' })
export class UserOrmEntity {
@PrimaryColumn('uuid')
id!: string;
@Column({ unique: true })
email!: string;
@Column({ name: 'is_active', default: false })
isActive!: boolean;
@Column({ name: 'created_at' })
createdAt!: Date;
}
4. The Mapper
We need a translator to convert the Pure Domain User to the TypeORM UserOrmEntity and vice-versa. This is where the decoupling physically happens.
// src/infrastructure/persistence/mappers/user.mapper.ts
import { User } from '../../../domain/user/user.entity';
import { UserOrmEntity } from '../entities/user.orm-entity';
export class UserMapper {
static toDomain(ormEntity: UserOrmEntity): User {
return User.create({
id: ormEntity.id,
email: ormEntity.email,
isActive: ormEntity.isActive,
createdAt: ormEntity.createdAt,
});
}
static toPersistence(domainEntity: User): UserOrmEntity {
const ormEntity = new UserOrmEntity();
ormEntity.id = domainEntity.id;
ormEntity.email = domainEntity.email;
ormEntity.isActive = domainEntity.isActive;
ormEntity.createdAt = domainEntity.createdAt;
return ormEntity;
}
}
5. The Adapter (Repository Implementation)
This class implements the Domain Port but uses TypeORM internally.
// src/infrastructure/persistence/repositories/typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserRepositoryPort } from '../../../domain/user/user.repository.port';
import { User } from '../../../domain/user/user.entity';
import { UserOrmEntity } from '../entities/user.orm-entity';
import { UserMapper } from '../mappers/user.mapper';
@Injectable()
export class TypeOrmUserRepository implements UserRepositoryPort {
constructor(
@InjectRepository(UserOrmEntity)
private readonly userRepository: Repository<UserOrmEntity>,
) {}
async save(user: User): Promise<void> {
const persistenceModel = UserMapper.toPersistence(user);
await this.userRepository.save(persistenceModel);
}
async findById(id: string): Promise<User | null> {
const entity = await this.userRepository.findOne({ where: { id } });
if (!entity) return null;
return UserMapper.toDomain(entity);
}
async findByEmail(email: string): Promise<User | null> {
const entity = await this.userRepository.findOne({ where: { email } });
if (!entity) return null;
return UserMapper.toDomain(entity);
}
}
6. Wiring it up in the Module
In NestJS, we bind the abstract UserRepositoryPort token to the concrete TypeOrmUserRepository implementation using a custom provider.
// src/user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserOrmEntity } from './infrastructure/persistence/entities/user.orm-entity';
import { TypeOrmUserRepository } from './infrastructure/persistence/repositories/typeorm-user.repository';
import { USER_REPOSITORY } from './domain/user/user.repository.port';
import { CreateUserUseCase } from './application/use-cases/create-user.use-case';
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity])],
providers: [
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
CreateUserUseCase,
],
exports: [USER_REPOSITORY],
})
export class UserModule {}
Why This Works
When you write your Use Cases (Application Services), you inject the interface, not the implementation:
// src/application/use-cases/create-user.use-case.ts
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY, UserRepositoryPort } from '../../domain/user/user.repository.port';
import { User } from '../../domain/user/user.entity';
@Injectable()
export class CreateUserUseCase {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepositoryPort
) {}
async execute(email: string): Promise<void> {
const newUser = User.create({
id: crypto.randomUUID(),
email,
isActive: true,
createdAt: new Date()
});
// The use case doesn't know TypeORM exists.
// It only knows it's passing a Domain Entity to a Port.
await this.userRepository.save(newUser);
}
}
Conclusion
By paying the "boilerplate tax" of mappers and separate entity files, you gain three critical advantages:
- Testability: You can unit test the
Userdomain class withnew User(). No mocks, no DB connection. - Flexibility: You can swap TypeORM for Prisma or raw SQL by creating a new Adapter and changing one line in your Module. The Domain code remains untouched.
- Purity: Your business rules are defined by TypeScript code, not by database column decorators.