Um Repositório, Três Bancos: Injeção de Dependência e a Livraria Independente de Infraestrutura

Um Repositório, Três Bancos: Injeção de Dependência e a Livraria Independente de Infraestrutura

Como deixar o domínio sem saber em que banco mora: contratos de repositório no domínio, implementações para EF Core e MongoDB, concorrência traduzida para uma exceção comum, contêiner de injeção de dependência com tempos de vida, testes de contrato e as abstrações que vazam.
Linguagem C#

• • 25 min de leitura

O domínio não deveria saber em que banco mora: com contratos no centro, implementações nas bordas e um contêiner de injeção de dependência montando tudo, trocar de banco vira uma linha de configuração.

"MongoDB Avançado" terminou com um balanço. Ao longo da Fase 5, a Livraria guardou seus dados num arquivo JSON, no SQLite, no PostgreSQL, no MySQL e no MongoDB, e o domínio sobreviveu intacto a todas as trocas. Mas, em cada aula, o código de acesso a dados se misturou ao programa principal, e trocar de banco significou reescrever partes dele. Nesta aula, que fecha a fase, levamos a separação até o fim. Os contratos de acesso a dados moram no domínio, cada banco tem sua implementação num projeto próprio, e um contêiner de injeção de dependência monta o sistema conforme a configuração. É a mesma engrenagem que o ASP.NET usa por dentro, e ela prepara a Livraria para a Fase 6.

O contrato mora no domínio

Em "Genéricos" escrevemos um IRepositorio<T> genérico, e em "Do Arquivo Único à Solução" vimos a regra das dependências: o domínio fica no centro e não conhece nenhuma camada externa. Juntando as duas ideias, o contrato de acesso aos produtos é declarado no domínio, com a linguagem do domínio:

namespace Livraria.Dominio.Produtos;

public interface IRepositorioProdutos
{
    Task<Produto?> ObterAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<Produto>> BuscarAsync(string termo, CancellationToken ct = default);
    Task AdicionarAsync(Produto produto, CancellationToken ct = default);
    Task AtualizarAsync(Produto produto, CancellationToken ct = default);
}

public sealed class ConflitoDeConcorrenciaException(int produtoId, Exception? causa = null)
    : Exception($"O produto {produtoId} foi alterado por outra operação. Recarregue e tente de novo.", causa)
{
    public int ProdutoId { get; } = produtoId;
}

Repare no que não aparece no contrato: DbContext, IMongoCollection, SQL, DbUpdateConcurrencyException. O domínio fala de produtos, buscas e conflitos, e nada mais. A exceção de conflito também é do domínio: cada implementação vai traduzir o erro técnico do seu banco para ela. Se o domínio deixasse vazar a exceção do EF Core, quem usa o repositório precisaria saber que o banco é relacional, e a separação seria só aparente.

Um detalhe decide se o contrato funciona para os três bancos: o tipo do identificador. O MongoDB gera ObjectId por padrão, e os bancos relacionais geram inteiros. Para manter um único tipo no domínio, os documentos do MongoDB usam _id inteiro, gerado pela coleção de contadores de "MongoDB: o Modelo de Documentos e o Driver Oficial". Decisões assim, que acomodam todos os bancos, são o preço de uma abstração comum, e voltaremos a esse preço no fim da aula.

Um serviço que só conhece o contrato

Acima do repositório fica a camada de aplicação: serviços que orquestram casos de uso, usando o domínio e os contratos. A venda de um produto:

namespace Livraria.Aplicacao;

public sealed class ServicoDeVendas(IRepositorioProdutos produtos)
{
    public async Task VenderAsync(int produtoId, int quantidade, CancellationToken ct = default)
    {
        for (int tentativa = 1; tentativa <= 3; tentativa++)
        {
            Produto produto = await produtos.ObterAsync(produtoId, ct)
                ?? throw new InvalidOperationException($"Produto {produtoId} não encontrado.");

            produto.Vender(quantidade);   // a regra de negócio de sempre

            try
            {
                await produtos.AtualizarAsync(produto, ct);
                return;
            }
            catch (ConflitoDeConcorrenciaException) when (tentativa < 3)
            {
                // outro caixa vendeu antes: recarrega e tenta de novo
            }
        }
    }
}

O serviço recebe o repositório pelo construtor, a injeção de dependência que fizemos à mão com as políticas de desconto em "Interfaces". Ele não sabe se o repositório grava no PostgreSQL, no MySQL, no MongoDB ou numa lista em memória dentro de um teste. A lógica de nova tentativa de "EF Core com Desempenho" agora vale para qualquer banco, porque todos traduzem seus conflitos para a mesma exceção do domínio. E o filtro when (tentativa < 3) deixa a exceção seguir na última tentativa, em vez de engoli-la.

Para que isso funcione, o repositório de cada banco precisa de uma forma de atribuir o identificador a um produto recém-gravado ou reconstruído. O EF Core faz isso pelo campo interno, como vimos. Para o MongoDB, o domínio ganha um método de reconstituição, visível só para os projetos de dados:

// em Produto
internal void AtribuirIdentidade(int id)
{
    if (Id != 0) throw new InvalidOperationException("A identidade de um produto não pode mudar.");
    Id = id;
}
// no arquivo de projeto do domínio, ou num arquivo AssemblyInfo.cs
[assembly: InternalsVisibleTo("Livraria.Dados.Mongo")]

InternalsVisibleTo, citado de passagem em "Testes Automatizados com xUnit", abre os membros internal do domínio para um projeto específico, e só para ele. O resto do sistema continua sem poder alterar o identificador de um produto.

As implementações, cada uma no seu projeto

A estrutura da solução passa a refletir a arquitetura:

src/
├── Livraria.Dominio/            entidades, regras e contratos (IRepositorioProdutos)
├── Livraria.Aplicacao/          casos de uso (ServicoDeVendas)
├── Livraria.Dados.EF/           LivrariaContext, configurações e RepositorioProdutosEf
├── Livraria.Dados.Postgres/     migrations do PostgreSQL e registro do provedor
├── Livraria.Dados.MySql/        migrations do MySQL e registro do provedor
├── Livraria.Dados.Mongo/        documentos, mapeamento e RepositorioProdutosMongo
└── Livraria.Cli/                o programa, que escolhe o banco pela configuração

O repositório do EF Core serve aos dois bancos relacionais:

namespace Livraria.Dados.EF;

public sealed class RepositorioProdutosEf(LivrariaContext contexto) : IRepositorioProdutos
{
    public Task<Produto?> ObterAsync(int id, CancellationToken ct = default) =>
        contexto.Produtos.Include(p => p.Autor).FirstOrDefaultAsync(p => p.Id == id, ct);

    public async Task<IReadOnlyList<Produto>> BuscarAsync(string termo, CancellationToken ct = default) =>
        await contexto.Produtos.AsNoTracking()
            .Include(p => p.Autor)
            .Where(p => p.Titulo.Contains(termo) || p.Autor.Nome.Contains(termo))
            .OrderBy(p => p.Titulo)
            .ToListAsync(ct);

    public async Task AdicionarAsync(Produto produto, CancellationToken ct = default)
    {
        contexto.Add(produto);
        await contexto.SaveChangesAsync(ct);
    }

    public async Task AtualizarAsync(Produto produto, CancellationToken ct = default)
    {
        try
        {
            await contexto.SaveChangesAsync(ct);   // o produto já está rastreado desde o ObterAsync
        }
        catch (DbUpdateConcurrencyException ex)
        {
            contexto.ChangeTracker.Clear();
            throw new ConflitoDeConcorrenciaException(produto.Id, ex);
        }
    }
}

AtualizarAsync aproveita o rastreamento do EF Core: o produto obtido pelo mesmo contexto já está sendo acompanhado, e o SaveChanges grava só o que mudou, com o token de concorrência do estoque. A exceção técnica é traduzida para a do domínio, e o rastreamento é limpo para que a próxima tentativa leia dados atuais.

As diferenças entre PostgreSQL e MySQL, como os arrays nativos de um e o conversor JSON do outro para os gêneros, ficam na configuração do modelo, que pergunta qual provedor está em uso:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(LivrariaContext).Assembly);

    if (Database.IsMySql())
    {
        modelBuilder.Entity<Produto>().Property(p => p.Generos)
            .HasConversion(
                g => JsonSerializer.Serialize(g, (JsonSerializerOptions?)null),
                j => JsonSerializer.Deserialize<string[]>(j, (JsonSerializerOptions?)null) ?? Array.Empty<string>())
            .HasColumnType("json");
    }
    // no PostgreSQL, string[] vira text[] sem configuração adicional
}

E, como migrations são específicas de cada provedor, cada banco tem as suas num projeto próprio, indicado na configuração com MigrationsAssembly. É a abordagem recomendada pela documentação do EF Core para aplicações com mais de um provedor.

O repositório do MongoDB não tem rastreamento de mudanças. Para oferecer a mesma garantia contra atualizações perdidas, ele mantém, durante sua vida, a versão de cada documento que leu, e usa essa versão como condição da gravação. É a concorrência otimista de "EF Core com Desempenho", implementada à mão:

namespace Livraria.Dados.Mongo;

public sealed class RepositorioProdutosMongo(IMongoDatabase banco) : IRepositorioProdutos
{
    private readonly IMongoCollection<ProdutoDocumento> _produtos = banco.GetCollection<ProdutoDocumento>("produtos");
    private readonly IMongoCollection<BsonDocument> _contadores = banco.GetCollection<BsonDocument>("contadores");
    private readonly Dictionary<int, long> _versoesLidas = new();

    public async Task<Produto?> ObterAsync(int id, CancellationToken ct = default)
    {
        ProdutoDocumento? documento = await _produtos.Find(d => d.Id == id).FirstOrDefaultAsync(ct);
        if (documento is null) return null;

        _versoesLidas[id] = documento.Versao;
        return MapeamentoMongo.ParaDominio(documento);
    }

    public async Task<IReadOnlyList<Produto>> BuscarAsync(string termo, CancellationToken ct = default)
    {
        List<ProdutoDocumento> documentos = await _produtos
            .Find(Builders<ProdutoDocumento>.Filter.Text(termo))
            .SortBy(d => d.Titulo)
            .ToListAsync(ct);
        return documentos.Select(MapeamentoMongo.ParaDominio).ToList();
    }

    public async Task AdicionarAsync(Produto produto, CancellationToken ct = default)
    {
        int id = await ProximoIdAsync(ct);
        produto.AtribuirIdentidade(id);
        await _produtos.InsertOneAsync(MapeamentoMongo.ParaDocumento(produto, versao: 1), cancellationToken: ct);
        _versoesLidas[id] = 1;
    }

    public async Task AtualizarAsync(Produto produto, CancellationToken ct = default)
    {
        if (!_versoesLidas.TryGetValue(produto.Id, out long versao))
            throw new InvalidOperationException("Só é possível atualizar produtos obtidos por este repositório.");

        ReplaceOneResult resultado = await _produtos.ReplaceOneAsync(
            d => d.Id == produto.Id && d.Versao == versao,
            MapeamentoMongo.ParaDocumento(produto, versao + 1),
            cancellationToken: ct);

        if (resultado.ModifiedCount == 0)
        {
            _versoesLidas.Remove(produto.Id);
            throw new ConflitoDeConcorrenciaException(produto.Id);
        }
        _versoesLidas[produto.Id] = versao + 1;
    }

    private async Task<int> ProximoIdAsync(CancellationToken ct)
    {
        BsonDocument contador = await _contadores.FindOneAndUpdateAsync(
            Builders<BsonDocument>.Filter.Eq("_id", "produtos"),
            Builders<BsonDocument>.Update.Inc("valor", 1),
            new FindOneAndUpdateOptions<BsonDocument> { IsUpsert = true, ReturnDocument = ReturnDocument.After },
            ct);
        return contador["valor"].AsInt32;
    }
}

O documento ganhou um campo Versao. Cada gravação exige que a versão no banco ainda seja a que foi lida e grava a seguinte. Se outro processo gravou antes, o filtro não encontra o documento, nada é substituído, e o conflito vira a exceção do domínio. MapeamentoMongo concentra as conversões entre produtos e documentos, com o pattern matching de "Arquivos, Streams e JSON", e usa AtribuirIdentidade para reconstruir o identificador.

Injeção de dependência com um contêiner

Até aqui, montamos os objetos à mão: criar o contexto, passá-lo ao repositório, passar o repositório ao serviço. Com três bancos, cada um com dependências diferentes, essa montagem vira um emaranhado de condicionais. Um contêiner de injeção de dependência assume esse trabalho. Você registra quais implementações existem e como devem viver, e ele resolve os objetos, criando cada dependência na ordem certa. O .NET tem um contêiner oficial, no pacote Microsoft.Extensions.DependencyInjection, e é exatamente ele que o ASP.NET usa por dentro.

O registro informa, para cada serviço, o seu tempo de vida:

  • Singleton: uma única instância para toda a aplicação. Adequado para objetos seguros entre threads e caros de criar, como o MongoClient e o NpgsqlDataSource.
  • Scoped: uma instância por escopo, a unidade de trabalho. Numa API, um escopo é uma requisição. Adequado para o DbContext e para os repositórios que dependem dele, pelas razões de "Entity Framework Core".
  • Transient: uma instância nova a cada vez que for pedida. Adequado para objetos leves e sem estado.

Cada projeto de dados oferece um método de extensão, o padrão da própria biblioteca do .NET, que registra tudo o que aquele banco precisa:

namespace Livraria.Dados.Postgres;

public static class RegistroPostgres
{
    public static IServiceCollection AdicionarLivrariaPostgres(this IServiceCollection servicos, string cadeia)
    {
        servicos.AddDbContext<LivrariaContext>(opcoes => opcoes
            .UseNpgsql(cadeia, npgsql => npgsql.MigrationsAssembly(typeof(RegistroPostgres).Assembly.GetName().Name))
            .UseSnakeCaseNamingConvention());
        servicos.AddScoped<IRepositorioProdutos, RepositorioProdutosEf>();
        return servicos;
    }
}
namespace Livraria.Dados.Mongo;

public static class RegistroMongo
{
    public static IServiceCollection AdicionarLivrariaMongo(this IServiceCollection servicos, string cadeia)
    {
        ConfiguracaoBson.RegistrarUmaVez();   // serializador de decimal e convenções de nomes
        servicos.AddSingleton<IMongoClient>(_ => new MongoClient(cadeia));
        servicos.AddSingleton(sp => sp.GetRequiredService<IMongoClient>().GetDatabase("livraria"));
        servicos.AddScoped<IRepositorioProdutos, RepositorioProdutosMongo>();
        return servicos;
    }
}

AddDbContext registra o contexto como Scoped, e o MongoClient é registrado como Singleton, cada um com o tempo de vida que suas aulas recomendaram. O RegistroMySql segue o molde do PostgreSQL, com UseMySql. Aqui o aviso de "MySQL" sobre o Pomelo cobra o seu preço: como os dois provedores compartilham o mesmo LivrariaContext, os dois precisam da mesma versão do EF Core. O Pomelo 9 exige o EF Core 9, e, junto com o Npgsql do EF Core 10, a compilação só avisa (NU1608), mas a primeira consulta termina em MissingMethodException (medido). Enquanto não houver Pomelo para o EF Core 10, a solução inteira fica no EF Core 9, ou o MySQL passa a usar o provedor da Oracle. E o programa escolhe o banco pela configuração:

using Livraria.Aplicacao;
using Livraria.Dados.Mongo;
using Livraria.Dados.MySql;
using Livraria.Dados.Postgres;
using Livraria.Dominio.Produtos;
using Microsoft.Extensions.DependencyInjection;

string provedor = Environment.GetEnvironmentVariable("LIVRARIA_PROVEDOR") ?? "postgres";
string cadeia = Environment.GetEnvironmentVariable("LIVRARIA_BANCO")
    ?? throw new InvalidOperationException("Defina a variável de ambiente LIVRARIA_BANCO.");

var servicos = new ServiceCollection();

switch (provedor)
{
    case "postgres": servicos.AdicionarLivrariaPostgres(cadeia); break;
    case "mysql":    servicos.AdicionarLivrariaMySql(cadeia); break;
    case "mongo":    servicos.AdicionarLivrariaMongo(cadeia); break;
    default: throw new InvalidOperationException($"Provedor desconhecido: {provedor}");
}

servicos.AddScoped<ServicoDeVendas>();

await using ServiceProvider raiz = servicos.BuildServiceProvider(validateScopes: true);

await using (AsyncServiceScope escopo = raiz.CreateAsyncScope())
{
    var repositorio = escopo.ServiceProvider.GetRequiredService<IRepositorioProdutos>();
    var vendas = escopo.ServiceProvider.GetRequiredService<ServicoDeVendas>();

    foreach (Produto p in await repositorio.BuscarAsync("sertão"))
        Console.WriteLine($"#{p.Id} {p.Titulo} ({p.Formato}), {p.Preco:C}");

    try
    {
        await vendas.VenderAsync(produtoId: 1, quantidade: 2);
        Console.WriteLine($"Venda registrada ({provedor}).");
    }
    catch (EstoqueInsuficienteException ex)
    {
        Console.WriteLine($"Venda recusada: {ex.Message}");
    }
}

O switch é o único lugar do sistema que conhece os três bancos. Ao pedir um ServicoDeVendas, o contêiner percebe que ele precisa de um IRepositorioProdutos, descobre qual implementação foi registrada, cria as dependências dela (o LivrariaContext ou o IMongoDatabase) e entrega o serviço pronto. O escopo, criado com CreateAsyncScope e descartado com await using, delimita a unidade de trabalho: o contexto do EF Core e o repositório do MongoDB, com suas versões lidas, vivem só dentro dele. validateScopes: true pede ao contêiner que verifique, durante o desenvolvimento, erros de tempo de vida, como o que o exercício 2 examina.

Trocar de banco passou a ser uma variável de ambiente: LIVRARIA_PROVEDOR=mongo. Nenhuma linha do domínio, da aplicação ou do programa principal muda.

Testes de contrato: a mesma suíte contra os três bancos

Três implementações de um mesmo contrato só são intercambiáveis se se comportarem da mesma forma. A ferramenta para garantir isso são os testes de contrato: uma única classe abstrata de testes, escrita contra a interface, e uma classe derivada para cada implementação. Para rodá-los contra bancos reais, a biblioteca Testcontainers sobe um contêiner Docker descartável para cada classe de testes:

public abstract class ContratoRepositorioProdutos
{
    protected abstract Task<IRepositorioProdutos> NovoRepositorioAsync();

    [Fact]
    public async Task Adicionar_DepoisObter_DevolveOMesmoProduto()
    {
        var repositorio = await NovoRepositorioAsync();
        var livro = new LivroImpresso("Dom Casmurro", new Autor("Machado de Assis"), 59.90m, pesoGramas: 350, estoque: 10);

        await repositorio.AdicionarAsync(livro);
        Produto? lido = await (await NovoRepositorioAsync()).ObterAsync(livro.Id);

        var impresso = Assert.IsType<LivroImpresso>(lido);
        Assert.Equal("Dom Casmurro", impresso.Titulo);
        Assert.Equal(59.90m, impresso.Preco);
        Assert.Equal(10, impresso.Estoque);
    }

    [Fact]
    public async Task Atualizar_ComDadoDesatualizado_LancaConflito()
    {
        var inicial = await NovoRepositorioAsync();
        var livro = new LivroImpresso("Vidas Secas", new Autor("Graciliano Ramos"), 38.00m, pesoGramas: 250, estoque: 1);
        await inicial.AdicionarAsync(livro);

        var caixa1 = await NovoRepositorioAsync();
        var caixa2 = await NovoRepositorioAsync();
        var noCaixa1 = (LivroImpresso)(await caixa1.ObterAsync(livro.Id))!;
        var noCaixa2 = (LivroImpresso)(await caixa2.ObterAsync(livro.Id))!;

        noCaixa1.Vender(1);
        await caixa1.AtualizarAsync(noCaixa1);

        noCaixa2.Vender(1);   // este objeto ainda vê 1 exemplar
        await Assert.ThrowsAsync<ConflitoDeConcorrenciaException>(() => caixa2.AtualizarAsync(noCaixa2));
    }
}

public sealed class RepositorioProdutosPostgresTestes : ContratoRepositorioProdutos, IAsyncLifetime
{
    private readonly PostgreSqlContainer _banco = new PostgreSqlBuilder("postgres:17").Build();

    public async Task InitializeAsync()
    {
        await _banco.StartAsync();
        await using var contexto = CriarContexto();
        await contexto.Database.MigrateAsync();
    }

    public Task DisposeAsync() => _banco.DisposeAsync().AsTask();

    protected override Task<IRepositorioProdutos> NovoRepositorioAsync() =>
        Task.FromResult<IRepositorioProdutos>(new RepositorioProdutosEf(CriarContexto()));

    private LivrariaContext CriarContexto() => new(new DbContextOptionsBuilder<LivrariaContext>()
        .UseNpgsql(_banco.GetConnectionString(), o => o.MigrationsAssembly("Livraria.Dados.Postgres"))
        .UseSnakeCaseNamingConvention()
        .Options);
}

As classes para MySQL e MongoDB seguem o mesmo molde, com MySqlContainer e MongoDbContainer. (A interface IAsyncLifetime do xUnit devolve Task na versão 2 e ValueTask na versão 3, e a assinatura acompanha a versão em uso.) Os testes são escritos uma vez e executados três vezes, uma contra cada banco real. O segundo teste é o mais valioso: ele garante que os três repositórios protegem contra a venda duplicada do último exemplar, cada um com seu mecanismo, o token de concorrência do EF Core ou a versão do documento no MongoDB. Se amanhã alguém "simplificar" um deles e remover a proteção, o teste daquele banco falha. É a disciplina de "Testes Automatizados com xUnit", agora aplicada a integração.

As abstrações vazam

Seria desonesto terminar sem o outro lado. Em 2002, Joel Spolsky formulou a "lei das abstrações que vazam": toda abstração não trivial deixa escapar, em algum grau, detalhes do que ela esconde. O nosso repositório comum não é exceção.

A busca. O mesmo BuscarAsync("sertao") encontra "Grande Sertão" no MySQL, cujo agrupamento padrão ignora acentos, e no MongoDB, pelo índice de texto, mas não no PostgreSQL sem unaccent. O contrato promete "buscar", e cada banco entende a promessa de um jeito. O exercício 3 trata de como tornar esse comportamento uniforme.

Os identificadores. Precisamos abrir mão do ObjectId do MongoDB para manter inteiros em todo lugar, e acrescentamos um contador que o banco não tinha.

As transações. O EF Core grava o pedido e a baixa de estoque numa transação sem esforço. No MongoDB, isso exige um conjunto de réplicas. Um contrato de "unidade de trabalho" comum teria de prometer só o que o banco mais limitado entrega.

O denominador comum. Ao programar contra o que os três bancos têm em comum, abrimos mão do que cada um tem de melhor: os arrays e o JSONB do PostgreSQL, o pipeline de agregação do MongoDB. Por isso os relatórios ficam, com frequência, fora do repositório comum, em consultas específicas de cada banco, como o Dapper ou o SqlQuery de "EF Core com Desempenho".

E há uma verdade prática: sistemas reais raramente trocam de banco de dados. O valor desta arquitetura não está em poder trocar o PostgreSQL pelo MongoDB numa sexta-feira à tarde. Está em três benefícios que se colhem todos os dias. O domínio e os casos de uso ficam testáveis sem banco nenhum, com um repositório em memória. As dependências ficam explícitas e apontam para o centro. E a infraestrutura fica isolada, de modo que atualizar o EF Core, mudar de provedor de MySQL ou migrar de um banco local para um serviço de nuvem afeta um projeto, e não o sistema inteiro.

Com isso, a Fase 5 está completa, e ela foi a mais longa do curso por um bom motivo: quase todo sistema real é, no fundo, uma forma organizada de guardar e consultar dados. Você começou escrevendo SQL no terminal e termina com a mesma Livraria funcionando sobre três bancos diferentes, escolhidos por configuração. No caminho, aprendeu a modelar tabelas, restrições e índices, a proteger o sistema contra SQL injection e contra a venda do último exemplar em dobro, a usar do ADO.NET puro ao Entity Framework, passando pelo Dapper, a enxergar e eliminar o problema N+1, a explorar os tipos ricos do PostgreSQL, as armadilhas do MySQL e o modelo de documentos do MongoDB, e a reconhecer quando cada um é a escolha certa.

A Livraria ainda é, porém, um programa de console, usado por uma pessoa de cada vez, na máquina onde roda. O mundo não chega a ela. Os caixas, o site, o aplicativo no celular e o jogo do capstone vão precisar falar com a Livraria pela rede, cada um de um lugar, ao mesmo tempo, e sem acesso direto ao banco de dados. Para isso, ela precisa de uma porta de entrada: uma API que recebe requisições pela web, aplica as regras do domínio e responde. E a boa notícia é que a engrenagem que acabamos de montar, com contêiner de injeção de dependência, escopos por unidade de trabalho e serviços que recebem suas dependências pelo construtor, é exatamente a engrenagem sobre a qual o ASP.NET Core é construído.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva o método de extensão AdicionarLivrariaMySql, que registra o contexto com o provedor Pomelo, a versão do servidor, o projeto de migrations do MySQL e o repositório do EF Core.

Ver resposta

✓ Resposta:

namespace Livraria.Dados.MySql;

public static class RegistroMySql
{
    public static IServiceCollection AdicionarLivrariaMySql(
        this IServiceCollection servicos, string cadeia, Version? versaoDoServidor = null)
    {
        var versao = new MySqlServerVersion(versaoDoServidor ?? new Version(8, 4, 0));

        servicos.AddDbContext<LivrariaContext>(opcoes => opcoes
            .UseMySql(cadeia, versao, mysql =>
                mysql.MigrationsAssembly(typeof(RegistroMySql).Assembly.GetName().Name))
            .UseSnakeCaseNamingConvention());

        servicos.AddScoped<IRepositorioProdutos, RepositorioProdutosEf>();
        return servicos;
    }
}

O registro é quase idêntico ao do PostgreSQL, e é esse o objetivo: o RepositorioProdutosEf e o LivrariaContext são compartilhados, e só o provedor, a versão e o projeto de migrations mudam. O compartilhamento tem uma condição, vista no corpo da aula: todos os provedores precisam da mesma versão do EF Core, o que hoje prende ao EF Core 9 quem usa o Pomelo. A versão do servidor é um parâmetro com valor padrão, em vez de ServerVersion.AutoDetect, pelas razões do exercício 4 de "MySQL". Assim ela pode vir da configuração junto com a cadeia de conexão. As diferenças de modelo entre os dois bancos, como os gêneros em JSON, não aparecem aqui: ficam no OnModelCreating, com Database.IsMySql(). Isso concentra num único lugar tudo o que depende do provedor dentro do modelo.

Exercício 2

Um colega registrou ServicoDeVendas como Singleton, mantendo o repositório como Scoped. O que acontece com validateScopes: true, e o que aconteceria sem essa verificação?

Ver resposta

✓ Resposta: Com a validação ligada, o contêiner lança uma InvalidOperationException ao resolver o serviço, com uma mensagem dizendo que não é possível consumir o serviço Scoped IRepositorioProdutos a partir do Singleton ServicoDeVendas. Sem a validação, o erro seria silencioso e grave. O singleton é criado uma vez e recebe um repositório, e com ele um DbContext, na sua criação. Esse contexto, que deveria viver só durante uma unidade de trabalho, fica preso dentro do singleton pelo resto da vida da aplicação. Esse problema tem nome: dependência cativa. As consequências são as do exercício 4 de "Entity Framework Core": o contexto é compartilhado entre operações simultâneas (e o EF Core lança erros de concorrência, ou pior, corrompe seu estado), o rastreamento acumula entidades indefinidamente (vazamento de memória) e as leituras devolvem dados velhos. A regra geral: um serviço só pode depender de serviços com tempo de vida igual ou mais longo que o seu. Um singleton pode depender de singletons, um serviço com escopo pode depender de serviços com escopo e de singletons, e um transiente pode depender de qualquer um. O ASP.NET liga essa validação automaticamente no ambiente de desenvolvimento.

Exercício 3

A busca por "sertao" encontra "Grande Sertão" no MySQL e no MongoDB, mas não no PostgreSQL. Proponha uma forma de tornar o comportamento de BuscarAsync igual nos três bancos, sem depender das particularidades de cada um.

Ver resposta

✓ Resposta: Uma solução independente do banco é normalizar o texto de busca no próprio domínio e guardá-lo num campo dedicado. Produto ganha uma propriedade TextoDeBusca, calculada a partir do título e do nome do autor, sem acentos e em minúsculas, e atualizada sempre que um deles muda:

using System.Globalization;
using System.Text;

public static class Normalizacao
{
    public static string ParaBusca(string texto)
    {
        string decomposto = texto.Normalize(NormalizationForm.FormD);
        var sb = new StringBuilder(decomposto.Length);
        foreach (char c in decomposto)
            if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
                sb.Append(char.ToLowerInvariant(c));
        return sb.ToString().Normalize(NormalizationForm.FormC);
    }
}

FormD separa cada letra acentuada em letra mais acento (o "ã" vira "a" seguido do til combinante), e descartar os caracteres da categoria NonSpacingMark remove os acentos. Os três repositórios passam a gravar esse campo e a buscar sobre ele, com o termo também normalizado: TextoDeBusca.Contains(Normalizacao.ParaBusca(termo)). O comportamento fica idêntico nos três bancos, porque a regra mora no domínio, e não nos agrupamentos, extensões ou índices de cada um. Com um índice sobre a coluna, a busca por prefixo fica eficiente. Para busca por qualquer parte do texto em grandes volumes, cada banco ainda precisaria do seu recurso próprio (busca textual, índices de trigramas), e aí a diferença volta, mas escondida dentro de cada repositório, sem mudar o contrato. É um exemplo de como tapar um vazamento de abstração: tirar a decisão do lugar onde ela varia e colocá-la no lugar que você controla.

Exercício 4

Escreva, na classe de contrato, um teste para BuscarAsync que garanta que a busca ignora maiúsculas e minúsculas e devolve os produtos em ordem de título.

Ver resposta

✓ Resposta:

[Fact]
public async Task Buscar_IgnoraCaixa_EDevolveEmOrdemDeTitulo()
{
    var repositorio = await NovoRepositorioAsync();
    var autor = new Autor("Graciliano Ramos");
    await repositorio.AdicionarAsync(new LivroImpresso("Vidas Secas", autor, 38.00m, pesoGramas: 250, estoque: 4));
    await repositorio.AdicionarAsync(new Ebook("São Bernardo", autor, 22.90m, tamanhoMb: 1.6));
    await repositorio.AdicionarAsync(new LivroImpresso("Dom Casmurro", new Autor("Machado de Assis"), 59.90m, pesoGramas: 350, estoque: 10));

    IReadOnlyList<Produto> encontrados = await (await NovoRepositorioAsync()).BuscarAsync("GRACILIANO");

    Assert.Equal(["São Bernardo", "Vidas Secas"], encontrados.Select(p => p.Titulo));
}

O teste cadastra dois livros do autor procurado e um de outro autor, busca com o termo inteiramente em maiúsculas e verifica duas coisas de uma vez: quais produtos voltaram (os dois de Graciliano, e não o de Machado) e em que ordem (alfabética por título). Assert.Equal sobre duas sequências compara elemento a elemento, na ordem. A busca é feita com um repositório novo, para garantir que os dados vêm do banco, e não de algum estado guardado na memória do repositório que gravou. Rodando nos três bancos, esse teste revela exatamente o vazamento do exercício anterior, se ele existir. É o valor do teste de contrato: tornar visível, e verificável, que as implementações cumprem a mesma promessa. Uma observação: "São Bernardo" vem antes de "Vidas Secas" em qualquer agrupamento razoável, mas ordenações com acentos no início das palavras podem diferir entre os bancos, e seria um bom caso para um teste adicional.

Exercício 5

Um gestor pergunta se vale a pena manter a Livraria compatível com os três bancos "para não ficar preso a um fornecedor". Escreva uma resposta equilibrada, com argumentos a favor e contra.

Ver resposta

✓ Resposta: A favor, a separação que construímos tem valor independentemente de trocar de banco: o domínio e os casos de uso são testáveis sem infraestrutura, as dependências são explícitas e a infraestrutura fica isolada em projetos próprios, o que torna atualizações e migrações menos arriscadas. Se a empresa vende o sistema para clientes que exigem bancos diferentes, ou se há uma migração concreta planejada, suportar mais de um banco é uma necessidade real, e os testes de contrato tornam isso seguro. Contra, manter três implementações ativas tem custo contínuo: três conjuntos de migrations, três ambientes de teste, três lugares para corrigir cada mudança no modelo e uma equipe que precisa conhecer os três bancos. Além disso, o denominador comum empobrece o sistema: abrir mão do JSONB, dos arrays e da busca textual do PostgreSQL, ou do pipeline de agregação do MongoDB, para caber no contrato comum tem um preço em funcionalidade e desempenho. E o risco que se quer evitar é pequeno: PostgreSQL e MySQL são de código aberto e disponíveis em todos os provedores de nuvem, e trocar de banco é um evento raro na vida de um sistema. A recomendação equilibrada: manter a arquitetura que permitiria trocar de banco (contratos no domínio, implementações isoladas, testes de contrato), mas manter ativa uma única implementação, a do banco escolhido, aproveitando plenamente os seus recursos. Se um dia a troca for necessária, o trabalho fica restrito a escrever uma nova implementação que passe nos mesmos testes, e não a reescrever o sistema. A independência que vale a pena comprar é a que custa pouco para manter.

Comentários

Mais em Linguagem C#

Godot com C#: nós, cenas e sinais
Godot com C#: nós, cenas e sinais

A Biblioteca em Apuros migra para o Godot com C#: tudo é um nó, nós formam…

Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso
Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso

O capstone: a Biblioteca em Apuros envia partidas que a API valida com as…

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…