🧠 Como criar uma Fluent API no TypeORM (igual ao Entity Framework)
Jhones Gonçalves

Jhones Gonçalves @jhonesgoncalves

About: 🤓 Staff Software Engineer at Stix 🚀 NodeJS e .NET Developer 👨‍🍳 Gastronomy Enthusiast 💍 Married to Cris Pereira 🤟 "Stay hungry"

Location:
Poços de Caldas / MG
Joined:
Aug 20, 2019

🧠 Como criar uma Fluent API no TypeORM (igual ao Entity Framework)

Publish Date: Oct 10 '25
4 2

💬 “O TypeORM não tem Fluent API igual ao Entity Framework...”

Pois é — não tinha.

Neste artigo eu vou te mostrar como implementei uma abordagem Fluent no TypeORM com NestJS + DDD,
permitindo definir entidades, enums e value objects sem decorators e sem poluir o domínio.


🎯 O problema

Se você vem do mundo .NET, provavelmente adora o EntityTypeConfiguration<T> do Entity Framework:
mapeamento fortemente tipado, fluente e separado do domínio.

Mas no TypeORM, o padrão é usar decorators:

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

Isso acopla o ORM ao domínio, dificulta testes e quebra a ideia de entidades puras do DDD.


🚀 A solução: criar uma Fluent API sobre o TypeORM

A ideia foi construir um EntityBuilder — uma camada que gera EntitySchema dinamicamente, imitando o EntityTypeBuilder do EF.

Assim, em vez de usar decorators, você escreve:

builder
  .property('id', { type: 'uuid', primary: true, generated: 'uuid' })
  .property('statusCode', { type: 'text', default: 'PENDING' })
  .index(['transactionNumber'], { unique: true });
Enter fullscreen mode Exit fullscreen mode

⚙️ A arquitetura base

src/
 ├── domain/
 │    ├── value-objects/
 │    │    └── cpf.vo.ts
 │    ├── enums.ts
 │    └── transaction.ts
 ├── infra/
 │    ├── fluent/
 │    │    ├── entity-builder.ts
 │    │    ├── configs/
 │    │    │    └── transaction.config.ts
 │    │    └── schema-registry.ts
 │    ├── database/
 │    │    └── database.module.ts
 │    └── repositories/
 │         └── transaction.repository.ts
 ├── application/
 │    └── transaction.service.ts
 ├── app.module.ts
 └── main.ts
Enter fullscreen mode Exit fullscreen mode

🧩 1. O domínio puro

Nada de decorators, nada de dependência com banco.

// src/domain/enums.ts
export enum TransactionType {
  CREDIT = 'CREDIT',
  DEBIT = 'DEBIT',
}

export enum TransactionStatus {
  PENDING = 'PENDING',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}
Enter fullscreen mode Exit fullscreen mode
// src/domain/value-objects/cpf.vo.ts
export class Cpf {
  private readonly value: string;

  constructor(value: string) {
    if (!Cpf.isValid(value)) throw new Error('CPF inválido');
    this.value = Cpf.clean(value);
  }

  getValue(): string {
    return this.value;
  }

  static clean(cpf: string): string {
    return cpf.replace(/\D/g, '');
  }

  static isValid(cpf: string): boolean {
    cpf = this.clean(cpf);
    if (cpf.length !== 11 || /^(\d)\1+$/.test(cpf)) return false;
    let sum = 0;
    for (let i = 0; i < 9; i++) sum += parseInt(cpf[i]) * (10 - i);
    let d1 = (sum * 10) % 11; if (d1 === 10) d1 = 0;
    if (d1 !== parseInt(cpf[9])) return false;
    sum = 0;
    for (let i = 0; i < 10; i++) sum += parseInt(cpf[i]) * (11 - i);
    let d2 = (sum * 10) % 11; if (d2 === 10) d2 = 0;
    return d2 === parseInt(cpf[10]);
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/domain/transaction.ts
import { TransactionType, TransactionStatus } from './enums';
import { Cpf } from './value-objects/cpf.vo';

export class Transaction {
  constructor(
    public id: string,
    public transactionNumber: number,
    public typeCode: TransactionType,
    public statusCode: TransactionStatus,
    public amountValue: number,
    public creationDate: Date,
    public cpf: Cpf,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

🧱 2. O EntityBuilder

Esse é o coração da Fluent API:

// src/infra/fluent/entity-builder.ts
import { EntitySchema } from 'typeorm';

export class EntityBuilder<T> {
  private name: string;
  private tableName: string;
  private columns: Record<string, any> = {};
  private indices: any[] = [];
  private relations: Record<string, any> = {};

  constructor(entity: { new (...args: any[]): T }, tableName?: string) {
    this.name = entity.name;
    this.tableName = tableName ?? entity.name.toLowerCase();
  }

  property(name: keyof T, options: any) {
    this.columns[name as string] = options;
    return this;
  }

  index(columns: (keyof T)[], options: any = {}) {
    this.indices.push({ columns: columns as string[], ...options });
    return this;
  }

  relation(name: string, options: any) {
    this.relations[name] = options;
    return this;
  }

  build(): EntitySchema<T> {
    return new EntitySchema<T>({
      name: this.name,
      tableName: this.tableName,
      columns: this.columns,
      indices: this.indices,
      relations: this.relations,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

⚙️ 3. Configuração fluente da entidade

Aqui está onde a mágica acontece:
você escreve o mapeamento igual ao EF, mas em TypeScript.

// src/infra/fluent/configs/transaction.config.ts
import { Transaction } from '../../../domain/transaction';
import { EntityBuilder } from '../entity-builder';
import { TransactionStatus } from '../../../domain/enums';
import { Cpf } from '../../../domain/value-objects/cpf.vo';

export class TransactionConfig {
  static configure(): EntityBuilder<Transaction> {
    const builder = new EntityBuilder<Transaction>(Transaction, 'transactions');

    builder
      .property('id', { type: 'integer', primary: true, generated: true })
      .property('transactionNumber', { type: 'integer', unique: true, nullable: true })
      .property('typeCode', { type: 'text' })
      .property('statusCode', { type: 'text', default: TransactionStatus.PENDING })
      .property('amountValue', { type: 'decimal', default: 0 })
      .property('creationDate', { type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
      .property('cpf', {
        type: 'text',
        transformer: {
          to: (cpf: Cpf) => cpf?.getValue(),
          from: (value: string) => new Cpf(value),
        },
      })
      .index(['transactionNumber'], { unique: true })
      .index(['creationDate']);

    return builder;
  }
}
Enter fullscreen mode Exit fullscreen mode

🧠 O transformer faz a ponte entre o banco e o domínio.

  • Quando grava → cpf.getValue()
  • Quando lê → new Cpf(value)

🧰 4. Registro automático no TypeORM

// src/infra/fluent/schema-registry.ts
import { TransactionConfig } from './configs/transaction.config';
export const Schemas = [TransactionConfig.configure().build()];
Enter fullscreen mode Exit fullscreen mode
// src/infra/database/database.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Schemas } from '../fluent/schema-registry';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'fluent-demo.db',
      synchronize: true,
      entities: Schemas,
    }),
    TypeOrmModule.forFeature(Schemas),
  ],
  exports: [TypeOrmModule],
})
export class DatabaseModule {}
Enter fullscreen mode Exit fullscreen mode

💾 5. Repositório e uso

// src/infra/repositories/transaction.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Transaction } from '../../domain/transaction';
import { TransactionConfig } from '../fluent/configs/transaction.config';

@Injectable()
export class TransactionRepository {
  constructor(
    @InjectRepository(TransactionConfig.configure().build())
    private readonly repo: Repository<Transaction>,
  ) {}

  async save(transaction: Transaction): Promise<Transaction> {
    return this.repo.save(transaction);
  }

  async findAll(): Promise<Transaction[]> {
    return this.repo.find();
  }
}
Enter fullscreen mode Exit fullscreen mode

🧪 6. Exemplo de uso no serviço

// src/application/transaction.service.ts
import { Injectable } from '@nestjs/common';
import { TransactionRepository } from '../infra/repositories/transaction.repository';
import { Transaction } from '../domain/transaction';
import { TransactionStatus, TransactionType } from '../domain/enums';
import { Cpf } from '../domain/value-objects/cpf.vo';

@Injectable()
export class TransactionService {
  constructor(private readonly repo: TransactionRepository) {}

  async createTransaction(): Promise<Transaction> {
    const txn = new Transaction(
      undefined,
      0,
      TransactionType.CREDIT,
      TransactionStatus.PENDING,
      100,
      new Date(),
      new Cpf('123.456.789-09'),
    );
    return this.repo.save(txn);
  }

  async getAll(): Promise<Transaction[]> {
    return this.repo.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

🚀 Resultado final

Ao rodar:

npm start
Enter fullscreen mode Exit fullscreen mode

Você verá no console:

✅ Created transaction: Transaction {
  id: 1,
  transactionNumber: null,
  typeCode: 'CREDIT',
  statusCode: 'PENDING',
  amountValue: 100,
  creationDate: 2025-10-10T12:00:00.000Z,
  cpf: Cpf { value: '12345678909' }
}
Enter fullscreen mode Exit fullscreen mode

🧭 Benefícios

Vantagem Descrição
🧩 Domínio puro Nenhum decorator ou dependência de ORM
🧱 Configuração centralizada Igual ao EntityTypeConfiguration do EF
🧠 Suporte a Value Objects Via transformer transparente
🚀 Compatível com NestJS e TypeORM Usa EntitySchema sob o capô
🔄 Extensível Pode adicionar .hasMany(), .default(), .enum(), etc.

🧭 Conclusão

Essa abordagem traz o melhor dos dois mundos:

  • O poder do EF Fluent API
  • A flexibilidade do TypeORM
  • E o isolamento do DDD.

Agora, seu domínio é totalmente independente da infraestrutura, e o mapeamento é expressivo, limpo e seguro.

Comments 2 total

  • Waldemir Francisco
    Waldemir FranciscoOct 10, 2025

    A abordagem é especialmente atraente para equipes vindas de .NET/EF que valorizam o estilo Fluent do Entity Framework. Porém, ela introduz um custo adicional na infraestrutura: ao adotar um EntityBuilder, cria-se uma camada própria que demanda disciplina, padronização e documentação.

    Venho de experiências com C# e .NET (minimal API) e reconheço o quanto o ecossistema Microsoft se integra bem, é excelente. Ao mesmo tempo, não há “certo” ou “errado” absoluto no nosso trabalho. O que conta é a sinergia entre as tecnologias e as ferramentas escolhidas para cada contexto.

    Vantagens:

    • Domínio limpo (alinhado a DDD).
    • Configuração centralizada e explícita.
    • Suporte direto a Value Objects (via transformers).
    • Menor acoplamento ao TypeORM.
    • Redução do “decoration hell”.

    Considerações:

    • Complexidade e overhead na infraestrutura.
    • Tipagem incompleta nas opções (risco de erros em runtime).
    • Menor aderência ao tooling padrão do TypeORM.
    • Possibilidade de duplicidade de mapeamentos e risco de divergência.
    • Impacto no boot time e na ordem de carga.
    • Menor apoio da comunidade/documentação por ser um padrão customizado.

    Uma excelente iniciativa, @jhonesgoncalves ! Publicar um package com essa proposta seria um passo significativo para facilitar a adesão a esse padrão. Além de encapsular a complexidade e oferecer uma interface mais acessível, um package permitiria um controle de versionamento, um changelog claro para acompanhar as evoluções e a possibilidade de uma adoção gradual pelas equipes.

    • Jhones Gonçalves
      Jhones GonçalvesOct 10, 2025

      Valeu demais pelo comentário, cara! 👏

      Curti muito sua análise — e concordo com vários dos pontos. Mas o ponto principal da ideia é justamente simplificar a infra, não deixar mais pesada.

      Normalmente, quando a gente separa entidade de banco e entidade de domínio, acaba tendo:

      • uma classe com os decorators do TypeORM,
      • outra pra representar o domínio puro,
      • e ainda um mapeamento no meio pra ligar as duas.

      O EntityBuilder veio pra resolver isso. Ele centraliza tudo num só lugar, gera o schema do TypeORM automaticamente e mantém o domínio limpo, sem precisar duplicar código ou criar mappers manuais.

      Outro bônus legal é que o tempo de inicialização melhora bastante — já que o builder monta o schema direto, sem depender do parser de decorators do TypeORM espalhados pelo projeto.

      Então, no fim das contas:

      • menos acoplamento,
      • menos boilerplate,
      • e uma inicialização mais rápida. 🚀

      E sim! Transformar isso num package é exatamente o plano — pra encapsular a ideia, deixar mais fácil de adotar e evoluir de forma organizada.

Add comment