O Unit of Work no Node.js coordena várias alterações de persistência como uma única unidade. Em vez de cada repository abrir e confirmar sua própria transação, o caso de uso recebe um conjunto de repositories vinculados à mesma conexão e decide quando confirmar ou reverter.
Esse padrão é útil quando uma operação atualiza múltiplos agregados, registra eventos em uma outbox ou precisa garantir que todas as mudanças sejam atômicas. Sem uma unidade compartilhada, o pedido pode ser salvo enquanto o estoque falha, deixando o sistema parcialmente atualizado.
Neste guia, você aprenderá a implementar Unit of Work com PostgreSQL e pg, expor repositories transacionais, evitar transações aninhadas, tratar erros, integrar outbox, testes, retries e observabilidade.
O que é Unit of Work?
Unit of Work acompanha ou coordena alterações realizadas durante uma operação e confirma todas em conjunto. O padrão é descrito no catálogo Patterns of Enterprise Application Architecture.
Em aplicações Node.js com PostgreSQL, uma implementação simples pode abrir um client do pool, iniciar BEGIN, criar repositories com esse client e executar um callback.
Para os fundamentos de banco, consulte Transações PostgreSQL no Node.js. Para contratos de persistência, veja Repository Pattern no Node.js.
Problema com transações separadas
await orderRepository.save(order);
await inventoryRepository.reserve(items);
await outboxRepository.add(event);Se cada método usa pool.query(), cada query pode executar em uma conexão diferente. Uma falha no terceiro passo não desfaz os anteriores.
Implementação básica
export class PgUnitOfWork {
constructor(private readonly pool: Pool) {}
async run<T>(
callback: (context: TransactionContext) => Promise<T>
): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const context = this.createContext(client);
const result = await callback(context);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}O callback nunca recebe o pool; recebe apenas componentes vinculados ao client.
Contexto transacional
type TransactionContext = {
orders: OrderRepository;
inventory: InventoryRepository;
outbox: OutboxRepository;
};A implementação cria cada repository:
private createContext(client: PoolClient): TransactionContext {
return {
orders: new PgOrderRepository(client),
inventory: new PgInventoryRepository(client),
outbox: new PgOutboxRepository(client)
};
}Interface Queryable
export interface Queryable {
query<T>(text: string, values?: unknown[]): Promise<QueryResult<T>>;
}Tanto Pool quanto PoolClient podem implementar o contrato necessário. Repositories ficam independentes do tipo concreto.
Caso de uso
export class CreateOrder {
constructor(private readonly uow: UnitOfWork) {}
async execute(input: CreateOrderInput) {
return this.uow.run(async tx => {
const order = Order.create(input);
await tx.inventory.reserve(order.items);
await tx.orders.add(order);
await tx.outbox.add(order.pullEvents());
return order.toDTO();
});
}
}Se reserva, inserção ou outbox falhar, tudo é revertido.
Transação pertence ao caso de uso
O Service Layer conhece a unidade de negócio. O repository conhece apenas persistência. Por isso, a decisão de iniciar e terminar a transação deve ficar na camada de aplicação.
Consulte Service Layer no Node.js.
Não exponha commit ao domínio
Entidades e serviços de domínio não devem chamar commit(). Persistência é uma responsabilidade externa.
Callback versus objeto manual
Uma API com callback reduz o risco de esquecer rollback ou release:
await uow.run(async tx => {
// operação
});Uma API manual:
const uow = await factory.begin();
try {
// operação
await uow.commit();
} catch (error) {
await uow.rollback();
} finally {
await uow.release();
}É mais flexível, mas mais fácil de usar incorretamente.
Rollback com erro no rollback
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
logger.error({ rollbackError }, 'Falha no rollback');
}
throw error;
}Preserve o erro original e registre a falha secundária.
Commit ambíguo
Se a conexão cai durante COMMIT, a aplicação pode não saber se o banco confirmou. Use idempotência e chaves de negócio para consultar o estado antes de repetir.
Consulte Idempotência em APIs Node.js.
Isolamento
await client.query(
'BEGIN ISOLATION LEVEL SERIALIZABLE'
);Permita configurar isolamento por caso de uso, mas use defaults seguros e documentados.
uow.run(callback, {
isolationLevel: 'serializable'
});Read-only
BEGIN TRANSACTION READ ONLY;Consultas que precisam de snapshot consistente podem usar transação somente leitura, embora muitas leituras simples não precisem de Unit of Work.
Timeouts locais
await client.query(`SET LOCAL lock_timeout = '2s'`);
await client.query(`SET LOCAL statement_timeout = '5s'`);Use valores apropriados ao caso de uso. Um timeout aborta a transação e exige rollback.
Transações curtas
Não execute dentro da Unit of Work:
- chamadas HTTP longas;
- upload para S3;
- envio de e-mail;
- espera por usuário;
- processamento pesado;
- sleep de retry;
- publicação síncrona em broker.
Essas operações mantêm conexão e locks ocupados.
Outbox transacional
O padrão Outbox é uma aplicação natural:
await tx.orders.save(order);
await tx.outbox.add({
id: crypto.randomUUID(),
type: 'order.created',
aggregateId: order.id,
payload: order.toEventPayload()
});Um worker publica depois. Veja Outbox Pattern no Node.js.
Eventos de domínio
Entidades podem acumular eventos, mas a Unit of Work decide como persistir:
const events = order.pullEvents();
await tx.outbox.addMany(events);Limpe os eventos somente depois que foram adicionados ao contexto transacional.
Repositories lazy
Se há muitos repositories, crie sob demanda:
get orders() {
return this._orders ??=
new PgOrderRepository(this.client);
}Isso evita instanciar componentes não utilizados.
Não use singleton transacional
Uma Unit of Work por requisição ou operação não deve ser compartilhada globalmente. Isso misturaria conexões e estados concorrentes.
AsyncLocalStorage
É possível armazenar o client no contexto assíncrono:
storage.run({ client }, callback);Repositories consultam o client atual. Essa abordagem reduz parâmetros, mas esconde dependências e exige testes cuidadosos. Consulte AsyncLocalStorage no Node.js.
Preferência por dependência explícita
Passar repositories no contexto é mais claro. Use AsyncLocalStorage somente quando o benefício operacional superar o custo de dependência implícita.
Transações aninhadas
PostgreSQL não possui transações aninhadas independentes. Se um serviço interno chama outra Unit of Work, pode abrir uma conexão diferente e quebrar atomicidade.
Propagação obrigatória
Uma política pode reutilizar o contexto existente:
uow.run(callback, { propagation: 'required' });Se já existe transação, usa a atual; caso contrário, cria uma.
Savepoints
Para uma suboperação opcional:
await client.query('SAVEPOINT optional_step');
try {
await optionalOperation();
await client.query('RELEASE SAVEPOINT optional_step');
} catch {
await client.query('ROLLBACK TO SAVEPOINT optional_step');
}Não use savepoint para esconder falhas que deveriam abortar o caso de uso.
ORMs
Prisma, Sequelize, TypeORM, Drizzle e Kysely possuem APIs de transação. A Unit of Work pode encapsular o client ou transaction object do ORM.
Exemplo com Kysely
await db.transaction().execute(async trx => {
const tx = createRepositories(trx);
await callback(tx);
});Consulte Kysely com TypeScript e SQL.
Exemplo com Drizzle
await db.transaction(async txDb => {
const context = createRepositories(txDb);
await callback(context);
});Consulte Drizzle ORM com PostgreSQL.
MongoDB
MongoDB também possui sessões e transações em replica sets. A Unit of Work pode carregar a session e garantir que todas as operações a recebam.
Não use transação para tudo
Uma única inserção atômica ou update condicional não precisa de uma abstração pesada. Use Unit of Work quando há coordenação real.
Retries de serialização
SQLSTATE 40001 e deadlock 40P01 podem ser repetidos com limites:
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await uow.run(callback, {
isolationLevel: 'serializable'
});
} catch (error) {
if (!isRetryable(error) || attempt === 2) throw error;
await delay(backoff(attempt));
}
}O callback não pode executar efeitos externos não idempotentes.
Locks
A Unit of Work não elimina deadlocks. Repositories devem adquirir locks em ordem consistente. Consulte Lock Pessimista no Node.js.
Observabilidade
Registre:
- nome do caso de uso;
- duração total;
- tempo para obter conexão;
- commit ou rollback;
- isolamento;
- SQLSTATE;
- retries;
- deadlocks;
- timeouts.
Não registre parâmetros sensíveis.
Métricas
Use counters e histograms:
unit_of_work_duration_seconds
unit_of_work_commits_total
unit_of_work_rollbacks_total
unit_of_work_retries_totalUse labels de baixa cardinalidade, como nome do caso de uso.
Teste unitário
Um fake pode executar callback diretamente com repositories em memória:
class InMemoryUnitOfWork {
async run(callback) {
const snapshot = structuredClone(this.state);
try {
return await callback(this.repositories);
} catch (error) {
this.state = snapshot;
throw error;
}
}
}O fake ajuda na regra, mas não reproduz banco real.
Teste de integração
Confirme:
- commit persiste todas as mudanças;
- erro reverte todas;
- conexão retorna ao pool;
- timeout provoca rollback;
- erro no outbox reverte agregado;
- dois casos concorrentes respeitam locks;
- retry serializable não duplica efeitos.
Falha antes do BEGIN
Se obter conexão falhar, não há rollback. Classifique como indisponibilidade do banco e aplique retry fora da Unit of Work quando apropriado.
Falha depois do COMMIT
Não execute lógica crítica depois do commit dentro da mesma função sem considerar que o banco já confirmou. Eventos externos devem vir da outbox.
Erros comuns
- Repository usa pool: escapa da transação.
- HTTP dentro do callback: transação fica longa.
- Unit of Work global: operações se misturam.
- Commit manual esquecido: conexão fica inconsistente.
- Transação aninhada: atomicidade é quebrada.
- Retry indiscriminado: efeitos externos duplicam.
- Sem timeout: locks ficam presos.
Boas práticas
- Use callback seguro.
- Crie repositories com o mesmo client.
- Coordene no Service Layer.
- Mantenha transações curtas.
- Use outbox para eventos.
- Configure timeouts locais.
- Evite transações aninhadas.
- Repita apenas erros transitórios.
- Monitore commits e rollbacks.
- Teste com banco real.
Conclusão
O Unit of Work no Node.js garante que vários repositories participem da mesma transação. O caso de uso passa a controlar uma unidade atômica, evitando estados parciais quando estoque, pedido e outbox precisam mudar juntos.
Uma implementação com callback, client exclusivo, repositories transacionais e timeouts é suficiente para muitos sistemas. Quando combinada com Service Layer, Repository Pattern, outbox e testes de rollback, a Unit of Work transforma operações compostas em fluxos previsíveis e seguros.




