Executando verificação de segurança...
2

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

Capa

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

Parte 2 – Persistência e Infraestrutura

Parte 3 – Apresentação e Integração


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.

Carregando publicação patrocinada...