Skip to main content

Hexagonal Architecture in NestJS: Decoupling TypeORM from Domain Entities

 

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:

  1. Domain: Pure TypeScript classes containing business logic. Zero dependencies.
  2. Infrastructure: TypeORM entities defining the table schema.
  3. 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:

  1. Testability: You can unit test the User domain class with new User(). No mocks, no DB connection.
  2. 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.
  3. Purity: Your business rules are defined by TypeScript code, not by database column decorators.