Em 2003, no livro Patterns of Enterprise Application Architecture, Martin Fowler catalogou um padrão chamado Unit of Work. A descrição é precisa: "Maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems". A intuição é que uma operação de negócio tipicamente toca vários registros — criar pedido, atualizar estoque, registrar movimentação financeira — e essas mudanças precisam ser atômicas. Unit of Work é o objeto que coleta as mudanças durante a operação e as commita (ou rola atrás) como uma unidade.
Vinte e três anos depois, Unit of Work permanece o padrão
mais comum para organizar transações em código de aplicação,
mesmo onde o nome não aparece. Entity Framework
DbContext é Unit of Work; SQLAlchemy
Session é Unit of Work; Hibernate
Session é Unit of Work; até a abstração mais
simples — abrir conexão, executar comandos, commitar — é
Unit of Work em forma elementar. A questão de cross-cutting
surge quando se pergunta: onde abrir e fechar a unit, e
como propagá-la pelas camadas?
A resposta clássica em frameworks como Spring é "abre a
transação no boundary do application service, via
anotação @Transactional, e propaga
transparentemente por todas as chamadas até o repositório".
É elegante quando funciona. Quando não funciona —
auto-injeção quebrada, transações aninhadas, cancelamento
no meio, transação esquecida porque o código atravessa fila
assíncrona — produz alguns dos bugs mais difíceis de
diagnosticar. Bug de "às vezes a operação salva metade", e
não há stack trace para começar a investigar.
Este conceito articula transação como aspect, mostra os três escopos canônicos (per-request, per-method, ambient), enuncia armadilhas específicas de cada framework, e propõe a heurística de "transação visível e curta" como caminho defensivo. As nuances de banco (isolation level, MVCC, deadlock) ficam para o módulo 03 — aqui o foco é a organização, não a semântica do banco em si.
Por que transação merece aspect
O argumento é direto. Em uma aplicação típica, quase toda
operação de escrita precisa de transação para garantir
atomicidade. Se cada handler de aplicação abrir e commitar
transação à mão, há três problemas. Primeiro, repetição —
using (var tx = ...) em cada handler é tangling.
Segundo, esquecimento — alguém adiciona handler novo e
esquece de envolver em transação; bug aparece em produção
como "às vezes salva, às vezes não". Terceiro, propagação —
se uma operação de aplicação chama outra que também precisa
de transação, as duas devem usar a mesma transação ou abrir
transações independentes? Em código manual, essa decisão é
espalhada e fica inconsistente.
Tratar transação como aspect resolve os três. Há uma decisão central — "operação de aplicação é uma transação" — e o aspect garante que cada execução do tipo abre a transação no início, commita no fim, e rola atrás se houver exceção. Quem chama nem sabe; o domínio nem sabe; o repositório usa o contexto compartilhado.
Os três escopos típicos
Per-request transaction
A unidade de transação é o request HTTP. Middleware abre uma
transação no início, todo o pipeline corre dentro dela, e o
middleware commita no fim (ou rola atrás se houve exceção).
Simples e robusto para sistemas onde cada request é
naturalmente uma unidade de trabalho — o caso comum em APIs
REST corporativas. Em ASP.NET Core,
IUnitOfWork registrado como Scoped
(escopo por request) já faz a maior parte. Em FastAPI, é o
padrão Depends(db_tx) com yield
mostrado no conceito 05.
Limitações: requests longos (export, upload pesado, geração de relatório) não devem ser uma transação inteira — segura o banco por minutos. Para esses casos, o handler abre e fecha transações menores explicitamente, ou o pipeline configura "não envolver em transação automática" para certas rotas.
Per-method transaction
A unidade é a chamada de método específico (use case,
command handler). Em Spring, é
@Transactional em método de service. Em .NET com
MediatR, é TransactionBehavior que envolve cada
command. Em Python, decorator @transactional em
use case. Em Go, não há equivalente idiomático
direto — a comunidade prefere transação explícita passada
como parâmetro.
Vantagens sobre per-request: granularidade fina; alguns use cases podem ser não-transacionais (read-only) e outros transacionais. Desvantagens: aumenta a chance de aninhamento (use case A chama use case B, ambos transacionais — o que acontece?), e a configuração espalha-se por anotações em muitas classes.
Ambient transaction
Transação que vive em "contexto ambiente" — em .NET,
TransactionScope com
System.Transactions. Você abre o escopo, e
qualquer conexão aberta dentro do escopo automaticamente
participa. Aninhamento, distribuição entre recursos
(transação distribuída via DTC) — tudo é tratado pelo
runtime.
É elegante na superfície e perigoso na prática. A
"promoção" de transação local para distribuída é
transparente, o que pode levar a comportamentos surpreendentes
em performance e em failure modes. TransactionScope
em .NET 8+ ganhou DefaultTransactionAttribute
mais conservador, e a recomendação atual é evitar ambient
transactions exceto em cenários muito específicos
(transações cross-database genuínas, frameworks legados que
assumem ambient).
Os padrões em código
Independente de escopo, três padrões aparecem em frameworks modernos. A escolha entre eles afeta legibilidade e previsibilidade.
Decorator/Behavior em torno do handler
O command handler é envolvido por um decorator/behavior que
abre transação antes da execução, commita depois, ou rola
atrás em exceção. Em MediatR (.NET), é
IPipelineBehavior registrado uma vez e aplicado
a todos os commands. Em FastAPI, Depends com
yield que abre/fecha transação. Em Spring,
@Transactional com proxy/interceptor.
// MediatR — TransactionBehavior em .NET
public class TransactionBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand
{
private readonly IUnitOfWork _uow;
private readonly ILogger _log;
public TransactionBehavior(IUnitOfWork uow, ILogger<TransactionBehavior<TRequest,TResponse>> log)
=> (_uow, _log) = (uow, log);
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
await using var tx = await _uow.BeginTransactionAsync(ct);
try
{
var response = await next();
await _uow.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return response;
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}
// registro: aplica a comandos, não a queries
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
Note três detalhes. Primeiro: o behavior
aplica a comandos (interface marcadora ICommand),
não a queries — leitura raramente precisa de transação
explícita. Segundo: SaveChangesAsync
acontece dentro da transação; o
CommitAsync é o fechamento explícito.
Terceiro: o handler de domínio nem sabe que
está em transação — só vê o repositório, que usa o contexto
EF compartilhado.
Dependency com yield (Python/FastAPI)
O FastAPI tem o padrão idiomático visto no conceito 05:
Depends com yield que abre antes do
handler e fecha depois. SQLAlchemy 2.x tem
begin() que faz commit ou rollback automático
via context manager.
from sqlalchemy.ext.asyncio import AsyncSession
async def db_tx(session_factory = Depends(get_session_factory)):
async with session_factory() as session:
async with session.begin(): # abre transação
yield session # handler executa aqui
# __aexit__ commita se ok, rolla atrás se houve exceção
@app.post("/pedidos")
async def criar_pedido(
cmd: CriarPedidoCmd,
tx: AsyncSession = Depends(db_tx),
) -> PedidoOut:
pedido = Pedido(cliente_id=cmd.cliente_id, itens=cmd.itens)
tx.add(pedido)
return PedidoOut.from_domain(pedido)
# se chegou aqui sem exceção, db_tx commita
# se exceção, db_tx rolla atrás
Transação explícita (Go)
Go evita o "ambient" e prefere transação como parâmetro
passado adiante. database/sql tem
BeginTx(ctx, opts) e
Tx.Commit()/Tx.Rollback()
manuais. A organização típica é o use case receber uma
connection-or-transaction abstraction e o ponto de
abertura ficar no handler ou em wrapper de application
service.
// Go — Unit of Work explícito
type UnitOfWork interface {
Do(ctx context.Context, fn func(tx *sql.Tx) error) error
}
type sqlUow struct{ db *sql.DB }
func (u *sqlUow) Do(ctx context.Context, fn func(tx *sql.Tx) error) error {
tx, err := u.db.BeginTx(ctx, nil)
if err != nil { return err }
defer func() {
if p := recover(); p != nil { tx.Rollback(); panic(p) }
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// uso no handler
func (h *Handlers) CriarPedido(w http.ResponseWriter, r *http.Request) {
var cmd CriarPedidoCmd
if err := decodeAndValidate(r, &cmd); err != nil { respondError(w, err); return }
err := h.uow.Do(r.Context(), func(tx *sql.Tx) error {
return h.uc.Criar(r.Context(), tx, cmd)
})
if err != nil { respondError(w, err); return }
respondJSON(w, 201, nil)
}
Verbosidade alta, controle total. A transação é objeto explícito — você sempre sabe se está em uma. É a versão "sem magia" do mesmo padrão.
Aninhamento — propagation
Quando um método transacional chama outro método transacional,
o que acontece? Spring nomeia esse comportamento de
propagation, e oferece sete opções:
REQUIRED (default — junta-se à existente ou abre
nova), REQUIRES_NEW (sempre nova, suspende a
existente), MANDATORY (exige que já exista uma),
NESTED (savepoint dentro da existente),
NEVER, SUPPORTS, NOT_SUPPORTED.
A propagation default REQUIRED é razoável: se já
tem transação, usa; se não, abre. Mas
REQUIRES_NEW é onde gente se machuca — abrir
transação nova suspendendo a atual significa que a primeira
pode commitar enquanto a segunda fez rollback, ou vice-versa.
Esse é o jeito mais elegante de quebrar consistência sem se
dar conta. Use REQUIRES_NEW apenas quando
explicitamente articulado, com motivo escrito (logging de
auditoria que precisa persistir mesmo se transação principal
falha, por exemplo).
Transação e operações async/queues
Aqui mora um dos bugs mais comuns. Operação de aplicação precisa: salvar pedido e publicar evento na fila para processamento downstream. Naive: salvar dentro da transação, publicar dentro da transação. Problema: e se o publish vai bem mas o commit falha? Você publicou evento sobre pedido que não existe. E se publish falha mas o commit foi feito? Você tem pedido sem evento downstream.
A solução madura é o outbox pattern: dentro da transação, você grava o evento numa tabela "outbox" no mesmo banco, junto com o pedido. Como ambos estão na mesma transação, ou os dois persistem ou nenhum. Outro processo (worker em background ou CDC via Debezium) lê a outbox e publica na fila com garantia at-least-once. Esse padrão é canônico em sistemas transacionais distribuídos, e sêniores que constroem APIs com eventos precisam saber.
Há também a armadilha mais simples: chamar API externa (HTTP) dentro da transação. Se a chamada externa demorar 5 segundos, a transação fica aberta 5 segundos — banco fica com locks, throughput cai. Regra: chamadas externas (não-transacionais) ficam fora do escopo da transação. Padrão: ler dado, fechar transação, chamar externa, abrir nova transação para escrever resultado.
@Transactional em método chamado via
this dentro da mesma classe — repete a
armadilha do conceito 06 (proxy). Em Spring AOP via proxy,
a chamada interna escapa do proxy, e o aspect transacional
não atua. O método executa, persiste, e a transação não
abriu — você acha que tem atomicidade e não tem. Sintoma:
"às vezes salva metade", aparecendo só sob falha. Solução
pragmática: sempre que método transacional chama outro,
chame via beans separados (interface ↔ outra classe), ou
injete a si mesmo (workaround feio mas funciona), ou use
AspectJ load-time weaving. Toda equipe Spring acaba sabendo
disso da pior forma; saber antes economiza um trimestre.
Onde a granularidade certa fica difícil
A pergunta sutil é: "qual a granularidade certa de transação?". Curta demais, você perde atomicidade desejada (duas operações que deveriam ser atômicas ficam em transações separadas). Longa demais, você segura o banco e prejudica throughput.
Heurística de senior: a transação deve corresponder à intenção do usuário. "Criar pedido" é uma intenção atômica — todas as operações que fazem o pedido acontecer (salvar pedido, decrementar estoque, registrar movimentação, enfileirar evento via outbox) ficam na mesma transação. Já "atualizar perfil" e "criar pedido" são intenções independentes — se o cliente faz as duas no mesmo request (raro), são duas transações.
Quando a intenção do usuário envolve operações que fisicamente não cabem em uma transação (chamadas a múltiplos sistemas, operações longas), cai-se no terreno de sagas e consistência eventual — tema do módulo 09. Por enquanto, a regra é: dentro de um sistema com um banco, transação cobre a intenção atômica; fora disso, é problema arquitetural, não problema de aspect.
Anti-padrões frequentes
Transação envolvendo chamada externa. Já mencionado. Solução: dividir em "ler do banco em transação curta", "chamar externa fora", "escrever resultado em outra transação curta".
Long-running transactions em jobs. Job batch que processa 1 milhão de registros em uma transação só — banco segura locks por horas, throughput de outras operações despenca, deadlocks aparecem. Solução: chunking. Divida em lotes de 1000-10000, transação por lote. Comprometimento de atomicidade total do job (em troca de operacionalidade) é decisão consciente.
Transações em queries de leitura. A maioria
dos bancos não exige transação explícita para
SELECT — autocommit basta. Aplicar transação em
query de leitura adiciona overhead sem benefício. Behaviors
que aplicam transação a tudo (sem distinguir command/query)
pagam esse custo gratuitamente.
Esquecer cancelamento. Em ASP.NET Core, se o
cliente desconecta no meio do request, o
CancellationToken é disparado. Operações de banco
precisam respeitar — caso contrário, query continua rodando
mesmo sem ninguém esperando. Em Spring, equivalente é
checar interrupção de thread; em Go, é context.Done().
Transação que ignora cancelamento desperdiça recursos.
Misturar isolation levels sem articular.
Default da maioria dos bancos é
READ COMMITTED ou similar. Promover algumas
transações para SERIALIZABLE sem que o time
saiba pode causar deadlocks ou serialization failures
surpreendentes. Quando isolation precisa subir, é decisão
explícita — geralmente em retry policy específica para
SerializationFailure.
Toda transação no sistema deve responder a três perguntas: "qual é a intenção atômica?", "quanto tempo ela tipicamente dura?", e "se ela falhar, o que precisa ser revertido?". Se a primeira pergunta tem resposta vaga ("salvar coisas"), a transação está mal recortada. Se a segunda tem resposta em segundos ou mais, há chamada externa ou operação cara dentro — vale revisar. Se a terceira tem resposta com "e também aquele evento que já saiu na fila", precisa de outbox pattern. Toda transação que merece o nome aspect passa nessas três perguntas.
Por que importa para a sua carreira
Transação aparece em entrevistas de design de sistemas com banco — e diferencia quem entende ACID de quem só lembra do acrônimo. Em revisão de código, perceber que um endpoint que atualiza pedido e estoque está usando transações separadas é ato de senior — geralmente esse bug ainda não apareceu, mas vai. Em pos-mortems de "salvou metade", a articulação de escopo de transação, propagação, e outbox pattern guia a correção. E em sistemas regulados (financeiro, contábil), saber distinguir transação de banco de "transação de negócio" — frequentemente nomes iguais, escopos diferentes — é vocabulário fundamental que o time de produto e o time de engenharia precisam compartilhar.
Como praticar
- TransactionBehavior em três linguagens. Implemente em .NET (MediatR pipeline behavior), Python (FastAPI dependency com yield), Go (Unit of Work explícito) o mesmo cenário: handler que cria pedido + decrementa estoque atomicamente. Force exceção no decremento; verifique que o pedido não foi salvo. Sem o aspect, as duas operações ficariam em transações separadas — exercício torna concreta a diferença.
- Outbox pattern. Adicione ao sistema acima publicação de evento "pedido.criado" via fila. Implemente outbox: dentro da transação, grave o evento numa tabela outbox; um worker em background lê e publica. Verifique que se publish falha, retry funciona; se commit do pedido falha, evento nunca sai. Esse exercício é o que separa quem ouviu falar de outbox de quem implementou.
-
Diagnóstico de armadilha de proxy em
Spring. Suba projeto Spring com dois métodos
transacionais, um chamando outro via
this. Verifique que a transação aninhada não atua. Refatore para as três soluções padrão (extrair classe, auto-inject, AspectJ LTW) e observe que cada uma resolve. Documente em meia página: qual abordagem você defenderia em projeto novo, e por quê.
Referências para aprofundar
- livro Patterns of Enterprise Application Architecture — Martin Fowler (Addison-Wesley, 2003).
- livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017).
- livro Domain-Driven Design — Eric Evans (2003).
- livro Implementing Domain-Driven Design — Vaughn Vernon (Addison-Wesley, 2013).
- livro Microservices Patterns — Chris Richardson (Manning, 2018).
- docs EF Core — Transactions.
- docs Spring Framework — Transaction Management.
- docs SQLAlchemy 2.0 — Session.
- docs Go database/sql — Tx and BeginTx.
- artigo The Outbox Pattern — Gunnar Morling (Debezium blog, 2019).
- artigo Don't Use Transactions in Your REST APIs — Vladimir Khorikov (blog, 2017).
- vídeo Transactional Boundaries in DDD — Vaughn Vernon (palestra, 2014+).