Três Olhares sobre Arquitetura de Software: Fowler, Evans e Uncle Bob: 2.3 - Unit of Work: coordenando mudanças no domínio

Na primeira parte desta série exploramos como o domínio pode ser modelado, protegido e orquestrado. Mas todo domínio, por mais elegante que seja, precisa encarar uma realidade inescapável: os dados precisam ser persistidos. Não basta calcular, validar ou expressar regras de negócio; é preciso garantir que esse estado sobreviva ao tempo. É nesse momento que a arquitetura encontra a infraestrutura, e que padrões se tornam decisivos para equilibrar clareza do modelo e eficiência técnica.
Martin Fowler dedicou uma boa parte do seu catálogo de Patterns of Enterprise Application Architecture justamente a esse tema: Active Record, Data Mapper, Unit of Work, Identity Map, Lazy Load. Cada um deles enfrenta dilemas recorrentes: como salvar e recuperar o estado do domínio sem deixá-lo refém do banco de dados? Como coordenar mudanças de forma transacional? Como manter performance sem sacrificar consistência?
Eric Evans, no Domain-Driven Design, sempre reforçou que a persistência deve ser invisível ao domínio. Para ele, o modelo precisa refletir a linguagem do negócio, não o schema do banco. Repositórios, Application Services e agregados só cumprem bem seu papel quando o banco fica em segundo plano, um detalhe de implementação.
Robert C. Martin, em Clean Architecture, é ainda mais radical: o banco de dados é um detalhe de infraestrutura, nunca o centro da arquitetura. Ele insiste que o domínio e os casos de uso não devem ter dependência alguma de frameworks ou tecnologias de armazenamento. O dado pode estar em SQL, NoSQL, arquivo em disco ou até mesmo em memória; o que importa é que o domínio continue protegido.
Nesta segunda parte da série, vamos olhar para os padrões de persistência com esses três óculos: Fowler, Evans e Uncle Bob. Vamos ver onde eles brilham, onde tropeçam e que escolhas arquiteturais nos forçam a enfrentar. E, como sempre, vamos usar exemplos em C# para tornar o debate mais concreto.
Índice da Série
Parte 1 – A lógica do domínio
- 1.1 Transaction Script: do procedural ao domínio rico
- 1.2 Table Module: tabelas falam mais alto que objetos?
- 1.3 Domain Model: o coração da arquitetura em três vozes
- 1.4 Service Layer: orquestrando casos de uso
Parte 2 – Persistência e Infraestrutura
- 2.1 Active Record: simplicidade que pode custar caro
- 2.2 Data Mapper: separando domínio e banco de dados
- 2.3 Unit of Work: coordenando mudanças no domínio
- 2.4 Identity Map & Lazy Load: truques de performance e consistência
Parte 3 – Apresentação e Integração
- 3.1 MVC e Front Controller: a porta de entrada
- 3.2 Template View e Page Controller: quando a UI dita o jogo
- 3.3 Gateways e Mappers: defendendo o domínio
- 3.4 Mensageria e integração: eventos em três perspectivas
Unit of Work: coordenando mudanças no domínio
Introdução
Quando trabalhamos com persistência, não basta apenas salvar e buscar entidades de forma isolada. A realidade é que, em quase todo caso de uso, múltiplos objetos são criados, modificados ou removidos em conjunto, e essas mudanças precisam ser persistidas de maneira atômica. É nesse contexto que Martin Fowler apresenta, em Patterns of Enterprise Application Architecture, o padrão Unit of Work.
O Unit of Work funciona como um gerente de alterações: rastreia tudo o que foi criado, modificado ou removido durante uma transação de negócio e, ao final, aplica as mudanças em conjunto. Tudo ou nada. Eric Evans, no Domain-Driven Design, lembra que esse padrão é infraestrutura, não domínio. Ele apoia os Application Services e os repositórios, mas nunca deve aparecer dentro de entidades ou value objects. Já Robert C. Martin, em Clean Architecture, reforça: o domínio não deve conhecer nada de transações ou bancos. O máximo que ele enxerga é uma abstração simples de Commit() e Rollback().
Exemplo prático em C#
Vamos considerar o caso de uso “Realizar Pedido”. Um pedido confirmado gera uma fatura, consome estoque e dispara eventos de integração. Nada disso pode acontecer pela metade. Se o pedido é salvo mas o estoque não é reservado, o sistema fica inconsistente. É para resolver esse problema que Martin Fowler descreveu o padrão Unit of Work.
// Domínio: Pedido gera Invoice como invariante
public class Order
{
private readonly List<OrderLine> _lines = new();
public OrderId Id { get; }
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines;
public Invoice? Invoice { get; private set; }
private Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public static Order Create(CustomerId customerId)
=> new(new OrderId(Guid.NewGuid()), customerId);
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public void Confirm()
{
if (!_lines.Any())
throw new InvalidOperationException("Cannot confirm empty order.");
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order already confirmed.");
Status = OrderStatus.Confirmed;
Invoice = new Invoice(new InvoiceId(Guid.NewGuid()), Id, Total());
}
public Money Total() =>
_lines.Aggregate(new Money(0, "USD"),
(acc, l) => acc.Add(l.UnitPrice.Multiply(l.Quantity)));
}
// Outras entidades
public record Invoice(InvoiceId Id, OrderId OrderId, Money Amount);
public record OrderLine(ProductId ProductId, int Quantity, Money UnitPrice);
public record OutboxMessage(Guid Id, string Type, string Payload, DateTime OccurredOnUtc);
public class InventoryItem
{
public ProductId ProductId { get; }
public int Available { get; private set; }
public InventoryItem(ProductId productId, int available)
{ ProductId = productId; Available = available; }
public void Reserve(int quantity)
{
if (Available < quantity) throw new InvalidOperationException("Insufficient stock.");
Available -= quantity;
}
}
// Value Objects
public readonly record struct OrderId(Guid Value);
public readonly record struct InvoiceId(Guid Value);
public readonly record struct CustomerId(Guid Value);
public readonly record struct ProductId(Guid Value);
public readonly record struct Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Currency mismatch.");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(int factor) => new(Amount * factor, Currency);
}
// Contratos de repositórios
public interface IOrderRepository
{
void Add(Order order);
}
public interface IInventoryRepository
{
InventoryItem GetByProductId(ProductId id);
void Update(InventoryItem item);
}
public interface IOutboxRepository
{
void Add(OutboxMessage message);
}
// Unit of Work (abstração)
public interface IUnitOfWork : IDisposable
{
void Commit();
void Rollback();
}
// Application Service
public class PlaceOrderService
{
private readonly IOrderRepository _orders;
private readonly IInventoryRepository _inventory;
private readonly IOutboxRepository _outbox;
private readonly IUnitOfWork _uow;
public PlaceOrderService(
IOrderRepository orders,
IInventoryRepository inventory,
IOutboxRepository outbox,
IUnitOfWork uow)
{
_orders = orders;
_inventory = inventory;
_outbox = outbox;
_uow = uow;
}
public OrderId Execute(CustomerId customerId, IEnumerable<(ProductId, int, Money)> lines)
{
var order = Order.Create(customerId);
foreach (var (productId, qty, price) in lines)
order.AddLine(productId, qty, price);
try
{
order.Confirm(); // Domínio gera a Invoice
// Reserva estoque
foreach (var line in order.Lines)
{
var stock = _inventory.GetByProductId(line.ProductId);
stock.Reserve(line.Quantity);
_inventory.Update(stock);
}
// Persiste pedido e invoice
_orders.Add(order);
// Registra evento de integração (Outbox)
var evt = new { order.Id, order.CustomerId, Lines = order.Lines };
var payload = System.Text.Json.JsonSerializer.Serialize(evt);
_outbox.Add(new OutboxMessage(Guid.NewGuid(), "OrderPlaced", payload, DateTime.UtcNow));
_uow.Commit();
return order.Id;
}
catch
{
_uow.Rollback();
throw;
}
}
}
Aqui, a Invoice nasce no domínio como uma invariante: todo pedido confirmado gera sua fatura. O serviço de aplicação apenas orquestra a transação: reserva o estoque, persiste o pedido (com a fatura embutida) e registra o evento. Tudo-or-nada, coordenado pelo Unit of Work.
O Entity Framework Core e o Unit of Work
O EF Core já implementa esse padrão em seu DbContext, que rastreia alterações e persiste tudo em lote via SaveChanges(). Mas expor diretamente o DbContext na aplicação cria acoplamento. Em linha com Evans e Uncle Bob, o ideal é encapsular o DbContext em repositórios e fornecer uma abstração de IUnitOfWork à aplicação. Assim, o Application Service fala em termos de Commit() e Rollback(), sem conhecer EF Core ou SQL.
O preço da coordenação
O Unit of Work garante consistência, mas não é gratuito: Fowler alerta para a complexidade de rastrear muitas entidades em memória. Evans reforça que é um detalhe técnico e não pode invadir o domínio. Uncle Bob lembra: se casos de uso dependem diretamente de APIs de transação específicas de banco, a arquitetura já foi comprometida.
O remédio é disciplina: manter o Unit of Work invisível ao domínio, encapsulado em uma interface mínima, servindo apenas para coordenar persistência nos Application Services.
Conclusão
O Unit of Work é o padrão que fecha o ciclo iniciado pelo Data Mapper e pelo Repository. Ele garante que múltiplas entidades e agregados sejam persistidos de forma atômica, respeitando invariantes de negócio como “pedido confirmado gera fatura”. Fowler o descreve como o gestor das alterações, Evans mostra como ele apoia a consistência sem poluir o modelo, e Uncle Bob o aceita como detalhe de infraestrutura, desde que não atravesse as fronteiras do domínio.
No próximo artigo, veremos padrões que tratam de performance e consistência em outro nível: Identity Map e Lazy Load, que ajudam a evitar duplicação de objetos e carregamento desnecessário, mas também trazem dilemas arquiteturais.