MÓDULO 05 · CONCEITO 12 DE 14

Transação & Unit of Work

TransactionScope, @transactional, escopo por request, ambient transactions. O aspect que mais escorrega — porque cada framework tem armadilha própria, e mistura silenciosa de transações é a causa-raiz mais cara em diagnóstico tardio.

Tempo de leitura ~22 min Pré-requisito Módulo 03 (bancos, transações) Próximo Validação cross-cutting

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.

armadilha em produção

@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.

heurística do sênior

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

  1. 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.
  2. 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.
  3. 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

  1. livro Patterns of Enterprise Application Architecture — Martin Fowler (Addison-Wesley, 2003). O catálogo original de Unit of Work, Identity Map, Repository. Vinte e três anos depois, ainda é a referência canônica para o vocabulário moderno de aplicação corporativa.
  2. livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017). Cap. 7 (Transactions) é o tratamento mais profundo de ACID, isolation levels, e weak isolation em livro técnico recente.
  3. livro Domain-Driven Design — Eric Evans (2003). Cap. 6 (The Life Cycle of a Domain Object) trata transação no contexto de aggregate boundaries — uma transação por aggregate é regra fundadora de DDD.
  4. livro Implementing Domain-Driven Design — Vaughn Vernon (Addison-Wesley, 2013). Capítulos 10 e 12 cobrem aggregate + Unit of Work com mais detalhes que Evans. Inclui discussão de quando usar transações distribuídas vs eventual consistency.
  5. livro Microservices Patterns — Chris Richardson (Manning, 2018). Cap. 4 (Managing Transactions with Sagas) e Cap. 5 (Transactional Messaging) cobrem outbox pattern e seus parentes com profundidade. Indispensável para sistemas distribuídos.
  6. docs EF Core — Transactions. learn.microsoft.com/en-us/ef/core/saving/transactions — Documentação atualizada de como EF Core gerencia transações, incluindo savepoint, isolation level, e integração com TransactionScope.
  7. docs Spring Framework — Transaction Management. docs.spring.io/spring-framework/reference/data-access/transaction.html — A documentação canônica do @Transactional, propagation, isolation. Páginas longas, mas cada parágrafo importa.
  8. docs SQLAlchemy 2.0 — Session. docs.sqlalchemy.org/en/20/orm/session_basics — A Session do SQLAlchemy é Unit of Work canônica. A documentação cobre lifecycle, autoflush, e padrões de uso.
  9. docs Go database/sql — Tx and BeginTx. pkg.go.dev/database/sql#DB.BeginTx — Documentação curta e precisa do que Go entrega na biblioteca padrão.
  10. artigo The Outbox Pattern — Gunnar Morling (Debezium blog, 2019). debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern — Tratamento canônico do outbox com Debezium. Morling é o lead do projeto.
  11. artigo Don't Use Transactions in Your REST APIs — Vladimir Khorikov (blog, 2017). enterprisecraftsmanship.com — Provocador, mas argumenta bem sobre os limites da transação per-request. Lê-se em 15 minutos e força reflexão.
  12. vídeo Transactional Boundaries in DDD — Vaughn Vernon (palestra, 2014+). YouTube. Vernon articula como aggregate boundaries e transações se relacionam — vale para sêniores que constroem domínios complexos.