DEV Community

Jhones Gonçalves
Jhones Gonçalves

Posted on

đź§  Como criar uma Fluent API no TypeORM (igual ao Entity Framework)

💬 “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.

Top comments (2)

Collapse
 
waldemirflj profile image
Waldemir Francisco • Edited

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.

Collapse
 
jhonesgoncalves profile image
Jhones Gonçalves

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.