💬 “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;
}
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 });
⚙️ 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
đź§© 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',
}
// 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]);
}
}
// 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,
) {}
}
đź§± 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,
});
}
}
⚙️ 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;
}
}
đź§ 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()];
// 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 {}
đź’ľ 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();
}
}
🧪 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();
}
}
🚀 Resultado final
Ao rodar:
npm start
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' }
}
đź§ 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)
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:
Considerações:
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.
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:
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:
E sim! Transformar isso num package é exatamente o plano — pra encapsular a ideia, deixar mais fácil de adotar e evoluir de forma organizada.