Três Olhares sobre Arquitetura de Software: Fowler, Evans e Uncle Bob: 2.4 - Identity Map & Lazy Load: truques de performance e consistência

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
Identity Map & Lazy Load: truques de performance e consistência
Introdução
Persistência não é apenas gravar e recuperar dados. É também garantir que, em memória, os objetos mantenham coerência com o banco de dados e que consultas pesadas só ocorram quando realmente necessárias. Em Patterns of Enterprise Application Architecture, Martin Fowler descreve dois padrões para isso: o Identity Map, que assegura que uma mesma linha do banco corresponda sempre à mesma instância em memória durante uma sessão, e o Lazy Load, que atrasa a carga de informações até que sejam de fato acessadas. Eric Evans, no Domain-Driven Design, reconhece a utilidade desses padrões, mas ressalta que eles pertencem à infraestrutura, não ao núcleo do modelo. Robert C. Martin, em Clean Architecture, reforça essa separação: o domínio deve permanecer puro, ignorando caching e estratégias de carregamento. Primeiro olhamos para os padrões no estilo “puro” de Fowler e, em seguida, vemos como adaptá-los ao espírito DDD/Clean no C# moderno, fechando com um exemplo direto em Entity Framework Core.
Identidade única na memória: Identity Map
Imagine buscar o mesmo cliente duas vezes em uma mesma transação. Sem proteção, você teria duas instâncias diferentes de Customer, cada uma com estado próprio, correndo o risco de inconsistência. O Identity Map resolve isso armazenando em cache os objetos já carregados. A ideia é simples: uma identidade, uma instância.
public abstract class DomainObject
{
public int Id { get; protected set; }
}
public sealed class Customer : DomainObject
{
public string Name { get; }
public Customer(int id, string name) { Id = id; Name = name; }
}
// Session-scoped Identity Map
public sealed class IdentityMap<T> where T : DomainObject
{
private readonly Dictionary<int, T> _cache = new();
public T? Get(int id) => _cache.TryGetValue(id, out var e) ? e : null;
public void Add(T entity) => _cache[entity.Id] = entity;
}
// Simple gateway using the Identity Map
public sealed class CustomerGateway
{
private readonly IdentityMap<Customer> _map = new();
public Customer GetById(int id)
{
var cached = _map.Get(id);
if (cached != null) return cached;
Console.WriteLine($"[SQL] SELECT Id, Name FROM Customers WHERE Id = {id}");
var c = new Customer(id, $"Customer-{id}");
_map.Add(c);
return c;
}
}
// Usage
var gw = new CustomerGateway();
var c1 = gw.GetById(1);
var c2 = gw.GetById(1);
Console.WriteLine(Object.ReferenceEquals(c1, c2)); // True
Objetos que “acordam”: Lazy Load com Ghosts (no estilo Fowler)
Carregar relacionamentos cedo demais pesa no I/O e na memória. O Lazy Load adia a carga. Fowler descreve três variantes: Lazy Initialization, Virtual Proxy e Ghost. No padrão Ghost, o objeto nasce “fantasma”, com apenas a identidade e uma flag IsGhost. Ao acessar uma propriedade que exige dados reais, o mapper hidrata o objeto e o marca como carregado. O MapperRegistry resolve o mapper correto por tipo, exatamente como no livro.
// Domain with minimal Ghost support
public abstract class DomainObject
{
public int Id { get; protected set; }
public bool IsGhost { get; private set; } = true;
public void MarkLoading() => IsGhost = true;
public void MarkLoaded() => IsGhost = false;
}
public sealed class Customer : DomainObject
{
private string? _name;
public string Name
{
get
{
if (IsGhost)
{
// Fowler-style: domain calls into a registry to trigger hydration
MapperRegistry.Get<Customer>().Load(this);
}
return _name ?? throw new InvalidOperationException("Name not loaded.");
}
}
internal void SetName(string name) { _name = name; }
public Customer(int id) { Id = id; MarkLoading(); }
}
// Infrastructure like in PoEAA (AbstractMapper + per-type registry)
public abstract class AbstractMapper<TEntity> where TEntity : DomainObject
{
public abstract TEntity Find(int id); // create the ghost
public abstract void Load(TEntity e); // hydrate on-demand
}
public sealed class CustomerMapper : AbstractMapper<Customer>
{
public override Customer Find(int id) => new Customer(id);
public override void Load(Customer e)
{
if (!e.IsGhost) return;
e.MarkLoading();
Console.WriteLine($"[SQL] SELECT Name FROM Customers WHERE Id = {e.Id}");
e.SetName($"Customer-{e.Id}");
e.MarkLoaded(); // ensure ghost becomes fully loaded
}
}
public static class MapperRegistry
{
private static readonly Dictionary<Type, object> _mappers = new();
public static void Register<TEntity>(AbstractMapper<TEntity> mapper)
where TEntity : DomainObject
=> _mappers[typeof(TEntity)] = mapper;
public static AbstractMapper<TEntity> Get<TEntity>()
where TEntity : DomainObject
=> (AbstractMapper<TEntity>)_mappers[typeof(TEntity)];
}
// Usage
MapperRegistry.Register(new CustomerMapper());
var cust = MapperRegistry.Get<Customer>().Find(1); // ghost object
Console.WriteLine(cust.Name); // triggers Load() on first access
Arquitetura limpa na prática: adiar sem acoplar, cachear sem poluir
Em uma abordagem alinhada a DDD/Clean, o domínio não conhece caching nem carregamento tardio. O Identity Map e o Lazy Load vivem na infraestrutura, normalmente dentro dos repositórios. No C# moderno, dá para injetar um Lazy no construtor e manter um mapa em memória por sessão. Observação: em muitos cenários você nem precisa implementar isso “na unha”, pois o Entity Framework Core já embute um Identity Map via ChangeTracker no DbContext, e oferece Lazy Loading Proxies opcionais que implementam carregamento sob demanda.
// Value Objects
public readonly record struct CustomerId(int Value);
public readonly record struct OrderId(int Value);
// Domain (persistence-ignorant)
public sealed class Order
{
public OrderId Id { get; }
public decimal Amount { get; }
public Order(OrderId id, decimal amount) {
Id = id;
Amount = amount;
}
}
public sealed class Customer
{
private readonly Lazy<IReadOnlyList<Order>> _orders;
public CustomerId Id { get; }
public string Name { get; }
public IReadOnlyList<Order> Orders => _orders.Value;
public Customer(CustomerId id, string name, Func<IReadOnlyList<Order>> ordersLoader)
{
Id = id;
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException(nameof(name)) : name;
_orders = new Lazy<IReadOnlyList<Order>>(ordersLoader);
}
}
// Infrastructure: Identity Map
public sealed class IdentityMap<TKey, TEntity>
where TKey : notnull
where TEntity : class
{
private readonly Dictionary<TKey, TEntity> _cache = new();
public TEntity? Get(TKey id) => _cache.TryGetValue(id, out var e) ? e : null;
public void Add(TKey id, TEntity entity) => _cache[id] = entity;
}
// Repository coordinating Identity Map + Lazy Load
public sealed class CustomerRepository
{
private readonly IdentityMap<CustomerId, Customer> _map = new();
public Customer GetById(CustomerId id)
{
var cached = _map.Get(id);
if (cached != null) return cached;
var customer = new Customer(id, "Alice", () => LoadOrders(id)); // lazy loader injected
_map.Add(id, customer); // identity map keeps uniqueness
return customer;
}
private IReadOnlyList<Order> LoadOrders(CustomerId id)
{
Console.WriteLine("[SQL] SELECT * FROM Orders WHERE CustomerId = @id");
return new List<Order>
{
new Order(new OrderId(301), 500m),
new Order(new OrderId(302), 700m)
};
}
}
// Usage
var repo = new CustomerRepository();
var a = repo.GetById(new CustomerId(1));
var b = repo.GetById(new CustomerId(1));
Console.WriteLine(Object.ReferenceEquals(a, b)); // True (Identity Map)
Console.WriteLine(a.Orders.Count); // Triggers Lazy Load on demand
Exemplo prático com Entity Framework Core
Para fechar, um exemplo direto com EF Core mostrando como o DbContext atua como Unit of Work + Identity Map (mesma identidade → mesma instância no contexto) e como habilitar Lazy Loading Proxies para carregamento sob demanda. O código ilustra a ideia; em produção, configure pacotes, migrações e conexões conforme seu ambiente.
// Install-Package Microsoft.EntityFrameworkCore
// Install-Package Microsoft.EntityFrameworkCore.Proxies
// Install-Package Microsoft.EntityFrameworkCore.InMemory
using Microsoft.EntityFrameworkCore;
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = default!;
// For EF Lazy Loading Proxies, navigation must be virtual
public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }
public int CustomerId { get; set; }
public virtual Customer Customer { get; set; } = default!;
}
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options
.UseLazyLoadingProxies() // enable Lazy Load via proxies
.UseInMemoryDatabase("demo");
}
}
// Usage
using var ctx = new AppDbContext();
// Seed
if (!ctx.Customers.Any())
{
var c = new Customer { Name = "Alice" };
c.Orders.Add(new Order { Amount = 500m });
c.Orders.Add(new Order { Amount = 700m });
ctx.Add(c);
ctx.SaveChanges();
}
// Identity Map via ChangeTracker: same identity => same instance within the context
var c1 = ctx.Customers.Find(1);
var c2 = ctx.Customers.Find(1);
Console.WriteLine(Object.ReferenceEquals(c1, c2)); // True
// Lazy Loading: accessing navigation triggers query on first access
Console.WriteLine(c1!.Orders.Count); // Triggers a query due to proxies
Conclusão
O Identity Map e o Lazy Load nasceram para resolver problemas reais de consistência e performance. Fowler nos mostrou os mecanismos em sua forma mais explícita, com ghosts, mappers e registries. Evans e Uncle Bob lembram que, embora úteis, esses padrões devem permanecer na infraestrutura, mantendo o domínio limpo e expressivo. No C# moderno, o EF Core já entrega um Identity Map embutido no DbContext e Lazy Loading via proxies; quando o contexto exigir mais controle, repositórios podem injetar Lazy e manter um IdentityMap por sessão, sem poluir o modelo.
No próximo artigo, saímos da persistência e abrimos a porta de entrada do sistema com MVC e Front Controller, comparando como cada autor enxerga essa interface entre usuários e domínio.