O site consumindo a API: clientes resilientes, cache condicional e um BFF com YARP

O site consumindo a API: clientes resilientes, cache condicional e um BFF com YARP

O site passa a conversar com a API de um jeito só: resiliência padrão com prazos e retentativas só onde é seguro, erros ProblemDetails traduzidos num único tipo, cache condicional com ETag num DelegatingHandler e um BFF com YARP que dá ao navegador uma única origem e guarda os tokens no servidor.
Linguagem C#

• • 19 min de leitura

O site já conversa com a API em vários pontos, cada um do seu jeito. Neste artigo essa conversa ganha regras: clientes HTTP com retentativas e prazos, erros traduzidos, cache que pergunta antes de baixar e um proxy reverso que dá ao navegador um único endereço e guarda as credenciais no servidor.

O que temos até aqui

Levantando o código dos últimos quatro artigos, o site chama a API por quatro caminhos diferentes:

Quem Como Problema
Razor Pages (vitrine, detalhe) CatalogoViaApi com HttpClient tipado sem retentativa, sem prazo, sem cache
Formulários (frete, avise-me) ICotadorDeFrete, RegistrarAvisoAsync cada um traduz ProblemDetails do seu jeito
Carrinho em WebAssembly endpoints /bff/* feitos à mão um endpoint novo para cada operação
Tempo real o navegador fala com o hub da API exige CORS e um endereço de API exposto ao público

Nenhum desses caminhos está errado, mas juntos são quatro formas de fazer a mesma coisa. Quando a API ficar lenta, e ela vai ficar, cada um vai falhar de um jeito diferente.

Uma regra organiza o artigo: quem chama um serviço remoto é responsável por decidir quanto esperar, quantas vezes tentar e o que mostrar quando não der certo. A API da Fase 6 já fez a parte dela, com contrato, ProblemDetails, ETag e rate limiting. Agora é a vez do cliente.

Um contrato, duas formas de segui-lo

O site usa os DTOs do projeto Livraria.Contratos, compartilhado com a API. É a forma mais simples de manter os dois lados alinhados, porque um campo renomeado quebra a compilação de ambos. O custo é o acoplamento: o site precisa ser recompilado quando o contrato muda, e só clientes .NET se beneficiam.

A alternativa é gerar o cliente a partir do documento OpenAPI que a API já produz em tempo de build desde "Validação, ProblemDetails e OpenAPI". O Kiota, gerador de clientes da Microsoft, produz um cliente fluente e tipado em C#, TypeScript, Java, Go, Python e outras linguagens:

dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate -l CSharp -d ../Livraria.Api/Livraria.Api.json \
  -c ClienteDaApi -n Livraria.Site.ApiGerada -o ./ApiGerada
var produto = await cliente.V1.Produtos[7].GetAsync(cancellationToken: ct);

Com geração, o contrato de verdade passa a ser o documento, que a verificação de compatibilidade do CI já protege. O aplicativo móvel e o jogo do capstone podem gerar seus próprios clientes da mesma fonte. A Livraria continua com os contratos compartilhados, porque todos os consumidores atuais são .NET, mas vale ter as duas opções em mente: quando surgir o primeiro cliente em outra linguagem, a geração passa a compensar.

Resiliência: esperar pouco, tentar de novo com critério

Chamadas pela rede falham de três formas: rápido (conexão recusada, 503), devagar (a API está sobrecarregada e demora 30 segundos para responder) ou de vez em quando (um pacote perdido, uma réplica reiniciando). A falha lenta é a mais perigosa. Cada requisição do site que espera a API ocupa uma thread de trabalho, memória e uma conexão. Se a API passa a responder em 30 segundos, o site acumula centenas de requisições paradas e cai também, arrastado. É a falha em cascata.

O pacote Microsoft.Extensions.Http.Resilience, construído sobre o Polly e já usado no cliente gRPC de "gRPC: contratos binários e streaming entre serviços", aplica uma estratégia padrão a um HttpClient:

builder.Services.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>(c =>
        c.BaseAddress = new Uri(builder.Configuration["Servicos:Api"]!))
    .AddStandardResilienceHandler(o =>
    {
        o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
        o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
        o.Retry.MaxRetryAttempts = 2;
        o.Retry.DisableForUnsafeHttpMethods();       // POST, PUT, DELETE não são repetidos
        o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
    });

A estratégia padrão empilha cinco camadas, de fora para dentro:

  1. Limitador de concorrência: um teto de chamadas simultâneas a esse serviço.
  2. Timeout total: o prazo máximo de toda a operação, com retentativas incluídas. Aqui, 6 segundos.
  3. Retentativa com espera exponencial e jitter (variação aleatória, para que mil clientes não tentem de novo no mesmo milissegundo), só para erros transitórios: falhas de rede, 5xx, 408 e 429. Respeita o Retry-After do rate limiting de "Produção".
  4. Circuit breaker: se a proporção de falhas numa janela passa de um limite, o circuito abre e as chamadas falham imediatamente, sem ir à rede, por um tempo. Depois deixa algumas passarem para testar. Isso dá à API espaço para se recuperar, em vez de ser martelada enquanto está caída, e faz o site falhar em milissegundos em vez de esperar.
  5. Timeout por tentativa: nenhuma tentativa individual espera mais de 2 segundos.

DisableForUnsafeHttpMethods merece destaque. Repetir um GET é inofensivo. Repetir um POST /pedidos cujo primeiro envio pode ter chegado, com a resposta perdida no caminho, é o pedido duplicado do exercício 5 de "A API da Livraria". Um POST só pode ser repetido com segurança quando carrega uma Idempotency-Key, e nesse caso a retentativa é habilitada explicitamente para aquele cliente.

Os números de prazo seguem um orçamento que vai de fora para dentro. O usuário tolera talvez 8 segundos por uma página. O site reserva 6 para a API. A API tem 2 segundos para o frete, com o prazo propagado por gRPC. Prazos internos maiores que os externos não têm sentido: o trabalho continuaria depois de quem pediu já ter desistido.

Erros: um formato, uma tradução

Hoje cada cliente do site lê as respostas de erro à sua maneira. Como a API sempre responde erros em ProblemDetails, o site pode ter uma única tradução:

public abstract record Resultado<T>
{
    public sealed record Ok(T Valor) : Resultado<T>;
    public sealed record NaoEncontrado : Resultado<T>;
    public sealed record Invalido(IDictionary<string, string[]> Erros) : Resultado<T>;
    public sealed record Conflito(string Mensagem) : Resultado<T>;
    public sealed record Indisponivel(string? TraceId) : Resultado<T>;
}

public static class RespostasDaApi
{
    public static async Task<Resultado<T>> LerAsync<T>(
        this HttpResponseMessage resposta, ILogger log, CancellationToken ct)
    {
        if (resposta.IsSuccessStatusCode)
            return new Resultado<T>.Ok((await resposta.Content.ReadFromJsonAsync<T>(ct))!);

        var problema = resposta.Content.Headers.ContentType?.MediaType == "application/problem+json"
            ? await resposta.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct)
            : null;
        var traceId = problema?.Extensions.TryGetValue("traceId", out var t) == true ? t?.ToString() : null;

        switch ((int)resposta.StatusCode)
        {
            case 404: return new Resultado<T>.NaoEncontrado();
            case 400 when problema?.Errors.Count > 0: return new Resultado<T>.Invalido(problema.Errors);
            case 409 or 412 or 422: return new Resultado<T>.Conflito(problema?.Detail ?? problema?.Title ?? "Conflito.");
        }

        log.LogWarning("API respondeu {Status} ({Titulo}); traceId {TraceId}",
            (int)resposta.StatusCode, problema?.Title, traceId);
        return new Resultado<T>.Indisponivel(traceId);
    }
}

Cada cliente fica com três linhas: montar a requisição, enviar e LerAsync. As páginas fazem switch sobre o Resultado, como o DetalheModel já fazia com a cotação. O traceId que a API colocou no ProblemDetails desde "Validação, ProblemDetails e OpenAPI" entra no log do site, e com o OpenTelemetry de "Produção" o rastro já liga os dois processos. O log do site, a página de erro amigável ("se o problema continuar, informe o código X") e o rastro distribuído passam a falar do mesmo identificador.

Exceções ficam reservadas para o que é realmente excepcional: TimeoutRejectedException e BrokenCircuitException da resiliência, e HttpRequestException sem resposta. Um try/catch no mesmo LerAsync, ou num DelegatingHandler, também as transforma em Indisponivel, e as páginas lidam com um único caminho de falha.

Cache condicional do lado do cliente

A API devolve ETag e Cache-Control: no-cache para produtos desde "Cache com Redis, HybridCache e output caching". O site ignorava esses cabeçalhos e baixava o corpo inteiro a cada exibição de página. Um DelegatingHandler, um middleware do lado do HttpClient, faz o site se comportar como um navegador bem-educado:

public sealed class CacheCondicional(IMemoryCache memoria) : DelegatingHandler
{
    private sealed record Guardado(string ETag, byte[] Corpo, string TipoDeConteudo);

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage requisicao, CancellationToken ct)
    {
        if (requisicao.Method != HttpMethod.Get)
            return await base.SendAsync(requisicao, ct);

        var chave = $"http:{requisicao.RequestUri}";
        memoria.TryGetValue(chave, out Guardado? guardado);
        if (guardado is not null)
            requisicao.Headers.IfNoneMatch.Add(EntityTagHeaderValue.Parse(guardado.ETag));

        var resposta = await base.SendAsync(requisicao, ct);

        if (resposta.StatusCode == HttpStatusCode.NotModified && guardado is not null)
        {
            resposta.Dispose();
            return Reconstruir(requisicao, guardado);
        }

        if (resposta.IsSuccessStatusCode && resposta.Headers.ETag is { } etag)
        {
            var corpo = await resposta.Content.ReadAsByteArrayAsync(ct);
            var tipo = resposta.Content.Headers.ContentType?.ToString() ?? "application/json";
            memoria.Set(chave, new Guardado(etag.ToString(), corpo, tipo),
                new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30), Size = corpo.Length });
            resposta.Content = new ByteArrayContent(corpo);
            resposta.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(tipo);
        }

        return resposta;
    }

    private static HttpResponseMessage Reconstruir(HttpRequestMessage requisicao, Guardado g)
    {
        var conteudo = new ByteArrayContent(g.Corpo);
        conteudo.Headers.ContentType = MediaTypeHeaderValue.Parse(g.TipoDeConteudo);
        return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = requisicao, Content = conteudo };
    }
}
builder.Services.AddMemoryCache(o => o.SizeLimit = 50 * 1024 * 1024);   // 50 MB
builder.Services.AddTransient<CacheCondicional>();

builder.Services.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>(...)
    .AddHttpMessageHandler<CacheCondicional>()
    .AddStandardResilienceHandler(...);

O resultado combina dois benefícios. Sempre atualizado, porque cada exibição pergunta à API "mudou?", e a resposta nunca é velha. Barato, porque quando nada mudou a resposta é um 304 sem corpo, a API responde com o DTO que já está no HybridCache, e o site não desserializa nada novo. Para a lista de mais vendidos do cabeçalho, que não precisa ser atualíssima, um HybridCache com expiração de um minuto no próprio site é ainda mais barato. A tabela de "quanto atraso cada dado tolera" de "Cache com Redis, HybridCache e output caching" vale também do lado do cliente.

A ordem dos handlers importa. Handlers acrescentados primeiro ficam mais por fora. Com o cache por fora da resiliência, uma resposta em cache não passa pelo circuit breaker, e uma retentativa não consulta o cache de novo a cada tentativa. O SizeLimit com Size por entrada impede que o cache cresça sem limite. Sem isso, um robô que percorre todos os produtos encheria a memória do site.

Um BFF de verdade com YARP

O carrinho em WebAssembly fala com o site por endpoints /bff escritos à mão. O hub de tempo real exigia que o navegador conhecesse o endereço da API e que a API liberasse CORS para o site. Com a área administrativa e o login chegando, o navegador também precisaria de tokens. O padrão Backend for Frontend resolve as três coisas de uma vez: o navegador fala só com o site, na mesma origem, e o site encaminha para a API o que for preciso, acrescentando credenciais do lado do servidor.

O YARP (Yet Another Reverse Proxy) é o proxy reverso da Microsoft, uma biblioteca que transforma uma aplicação ASP.NET Core num proxy configurável. É o mesmo componente que roda na frente de serviços internos da própria Microsoft.

dotnet add src/Livraria.Site package Yarp.ReverseProxy
"ReverseProxy": {
  "Routes": {
    "api": {
      "ClusterId": "livraria-api",
      "Match": { "Path": "/api/{**resto}" },
      "Transforms": [
        { "PathRemovePrefix": "/api" },
        { "RequestHeaderRemove": "Cookie" }
      ]
    }
  },
  "Clusters": {
    "livraria-api": {
      "Destinations": { "principal": { "Address": "https://api.livraria.interno/" } }
    }
  }
}
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(contexto =>
    {
        contexto.AddRequestTransform(async transformacao =>
        {
            var token = await transformacao.HttpContext.GetTokenAsync("access_token");
            if (token is not null)
                transformacao.ProxyRequest.Headers.Authorization = new("Bearer", token);
        });
    });

// ...
app.UseMiddleware<ExigeCabecalhoAntiCsrf>();
app.MapReverseProxy();

Uma requisição do navegador para /api/v1/produtos/7 sai do site como GET https://api.livraria.interno/v1/produtos/7:

  • PathRemovePrefix tira o /api, e a API não sabe que existe um proxy na frente.
  • RequestHeaderRemove: Cookie impede que os cookies do site (sessão, carrinho) vazem para a API, que não precisa deles.
  • O transform de autorização lê o access token guardado na sessão do usuário do lado do servidor e o anexa como Bearer. O navegador nunca vê o token, que fica fora do alcance de qualquer XSS. É o desenho recomendado em "Autenticação e autorização com JWT". Enquanto ninguém está logado, GetTokenAsync devolve nulo e a requisição segue anônima. O login que preenche esse token é assunto do próximo artigo.
  • WebSockets passam pelo YARP sem configuração extra. O hub fica acessível em /api/hubs/livraria, na mesma origem do site. Para o navegador, o CORS da API deixa de ser necessário. Ele continua configurado para outros clientes que chamem a API diretamente.
  • O YARP propaga traceparent e acrescenta os cabeçalhos X-Forwarded-*, e o rastro distribuído continua atravessando o proxy. Por isso a API deve confiar nesses cabeçalhos só vindos do endereço do site, como em "Produção".

O proxy também tem balanceamento entre destinos, verificações de saúde ativas e passivas e as mesmas políticas de rate limiting, autorização e output cache do ASP.NET Core por rota, porque é, afinal, uma aplicação ASP.NET Core.

CSRF no BFF

Com o token agora anexado automaticamente pelo servidor a partir de um cookie de sessão, o risco de CSRF de "Formulários e validação" volta: um site malicioso poderia fazer o navegador da vítima chamar /api/v1/pedidos, e o proxy anexaria o token. Formulários HTML de outros sites só conseguem enviar tipos de conteúdo simples, e o SameSite=Lax já barra a maior parte. A defesa adicional padrão em BFFs é exigir um cabeçalho personalizado. Um site de outra origem não consegue acrescentá-lo sem passar por um preflight CORS, que o site recusa:

public sealed class ExigeCabecalhoAntiCsrf(RequestDelegate proximo)
{
    public Task InvokeAsync(HttpContext contexto)
    {
        var caminho = contexto.Request.Path;
        var protegido = (caminho.StartsWithSegments("/api") && !caminho.StartsWithSegments("/api/hubs"))
                        || caminho.StartsWithSegments("/bff");
        var seguro = HttpMethods.IsGet(contexto.Request.Method) || HttpMethods.IsHead(contexto.Request.Method);

        if (protegido && !seguro && contexto.Request.Headers["X-CSRF"] != "1")
        {
            contexto.Response.StatusCode = StatusCodes.Status400BadRequest;
            return Task.CompletedTask;
        }
        return proximo(contexto);
    }
}

No projeto cliente, o HttpClient passa a enviar o cabeçalho por padrão: http.DefaultRequestHeaders.Add("X-CSRF", "1"). O hub fica de fora da exigência porque o cliente do SignalR abre a conexão com um POST .../negotiate que não carrega esse cabeçalho. O negotiate não altera nada, e a conexão WebSocket que vem depois é um GET.

Os endpoints /bff encolhem

Com o proxy, o carrinho em WebAssembly pode buscar o catálogo e cotar frete direto pela API, em /api/v1/..., e o CotadorViaBff feito à mão desaparece. Os endpoints /bff/carrinho continuam, e por um bom motivo: o carrinho é estado do site, guardado no Redis do site, e não um recurso da API. O proxy encaminha o que é da API. O BFF implementa o que é do front-end. Com essa distinção, cada endpoint tem um dono claro.

Testando o site contra a API real

A WebApplicationFactory de "Configuração, logging e testes de integração" permite um teste que sobe os dois processos em memória e liga um ao outro sem rede:

public sealed class FabricaDoSite(FabricaDaApi api) : WebApplicationFactory<Livraria.Site.Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseSetting("Servicos:Api", api.Server.BaseAddress.ToString());
        builder.ConfigureTestServices(servicos =>
        {
            servicos.AddHttpClient<ICatalogoDaLoja, CatalogoViaApi>()
                .ConfigurePrimaryHttpMessageHandler(() => api.Server.CreateHandler());
        });
    }
}

O HttpClient do site passa a usar o handler do TestServer da API como transporte. O teste faz GET /livros/1 no site, e a requisição atravessa o site, o cliente tipado com cache e resiliência, a API em memória e o PostgreSQL no contêiner. O teste afirma que o HTML contém o título. É o teste mais próximo do que um usuário faz, e roda em segundos.

A camada de resiliência também merece testes próprios, com um handler falso que devolve 503 duas vezes e 200 na terceira, ou que demora 5 segundos. Esses testes são a única forma de saber, antes de produção, se o timeout de fato corta a espera e se o circuit breaker de fato abre.

Agora o site conversa com a API de um jeito só: clientes tipados que desistem a tempo, tentam de novo só o que pode ser repetido e param de insistir quando a API está caída; erros que chegam como um tipo que as páginas sabem exibir, com um identificador que leva ao rastro; um cache que nunca serve dado velho e quase nunca baixa dado repetido; e um proxy que dá ao navegador uma única origem e mantém as credenciais no servidor. O transform do proxy está pronto para anexar um token que ainda não existe, porque ninguém faz login no site.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega configurou AttemptTimeout de 10 s, TotalRequestTimeout de 6 s e 3 retentativas. Descreva o comportamento real quando a API está travada, e corrija a configuração.

Ver resposta

✓ Resposta: Ela nem chega a rodar. A estratégia padrão valida as opções, e a primeira chamada lança OptionsValidationException: "Total request timeout resilience strategy must have a greater timeout than the attempt resilience strategy" (medido no .NET 10, com o pacote 10.10). A validação existe porque a combinação não faz sentido: o timeout total, de 6 s, envolve todas as tentativas, então a primeira tentativa seria cortada aos 6 s, antes dos 10 s dela, e nenhuma das 3 retentativas chegaria a acontecer. A correção segue o orçamento: timeout por tentativa menor que o total dividido pelo número de tentativas somado às esperas entre elas. Por exemplo, total de 6 s, tentativa de 1,5 s, 2 retentativas com espera exponencial curta (cerca de 0,2 s e 0,4 s). Assim há espaço real para três tentativas, e uma réplica travada é abandonada a tempo de tentar outra.

Exercício 2

Por que o CacheCondicional fica por fora do AddStandardResilienceHandler? O que mudaria com a ordem invertida?

Ver resposta

✓ Resposta: Por fora (registrado primeiro), o cache é consultado uma vez por chamada lógica: ele acrescenta If-None-Match, e a resiliência cuida das tentativas de rede com esse cabeçalho já presente. Uma resposta 304 é convertida em 200 fora da resiliência, que não precisa entender nada de cache. Com a ordem invertida, o cache ficaria dentro de cada tentativa. Cada retentativa passaria por ele de novo, o que é inofensivo mas redundante. O mais importante: o circuit breaker e os limitadores de concorrência passariam a observar respostas vindas do cache como se fossem da rede, distorcendo as estatísticas de falha e sucesso da API. A regra geral é que aquilo que evita ir à rede fica por fora, e aquilo que trata falhas de rede fica por dentro.

Exercício 3

O navegador chama /api/v1/pedidos pelo proxy, e a API responde 401 porque o access token expirou há um minuto. O que o BFF deveria fazer, e por que isso não deve ser resolvido no JavaScript do navegador?

Ver resposta

✓ Resposta: O BFF deve renovar o token do lado do servidor antes de encaminhar: verificar a expiração guardada junto com o token na sessão, e se faltar pouco (ou já tiver passado), usar o refresh token, também guardado no servidor, para obter um novo par no provedor de identidade, atualizar a sessão e então encaminhar a requisição com o token novo. Se a renovação falhar, porque o refresh token foi revogado ou expirou, responde 401 ao navegador, e a aplicação redireciona para o login. Resolver no navegador exigiria que o JavaScript tivesse acesso ao refresh token, que é justamente a credencial mais valiosa e de vida mais longa, e o BFF existe para nunca expô-la. Bibliotecas de BFF, como o Duende.BFF, e o próprio middleware de OpenID Connect com SaveTokens oferecem essa renovação automática, que é parte do próximo artigo.

Exercício 4

Escreva um teste de unidade da resiliência que prove que um POST não é repetido quando a API responde 503, e que um GET é.

Ver resposta

✓ Resposta: Crie um DelegatingHandler falso que conta chamadas e sempre devolve HttpStatusCode.ServiceUnavailable. Monte um contêiner: var servicos = new ServiceCollection(); servicos.AddHttpClient("teste", c => c.BaseAddress = new Uri("http://api/")).ConfigurePrimaryHttpMessageHandler(() => falso).AddStandardResilienceHandler(o => { o.Retry.MaxRetryAttempts = 2; o.Retry.Delay = TimeSpan.Zero; o.Retry.DisableForUnsafeHttpMethods(); }); (nessa ordem: AddStandardResilienceHandler devolve outro tipo de builder, e encadear o ConfigurePrimaryHttpMessageHandler depois dele não compila, CS1929). Resolva o IHttpClientFactory e crie o cliente. Chame PostAsync("pedidos", conteudo), verifique Assert.Equal(1, falso.Chamadas), zere o contador, chame GetAsync("produtos/1") e verifique Assert.Equal(3, falso.Chamadas): 1 tentativa mais 2 retentativas. Um cuidado: com Delay = TimeSpan.Zero o teste é rápido, mas o circuit breaker pode abrir se a amostragem vir falhas suficientes. Ajuste MinimumThroughput para um valor alto no teste, ou use instâncias separadas para cada caso.

Exercício 5

Depois de introduzir o YARP, alguém propõe remover a política de CORS da API "porque agora tudo passa pelo site". Em que situação essa remoção quebraria algo, e quando ela seria segura?

Ver resposta

✓ Resposta: CORS só importa para navegadores chamando a API diretamente de outra origem. A remoção quebraria qualquer front-end de navegador que ainda chame a API sem passar pelo proxy: uma versão antiga do carrinho em cache no navegador de usuários durante a transição, um painel interno servido de outro domínio, uma página de parceiro. Aplicativos móveis, o jogo e outros serviços não são afetados, porque não aplicam CORS. A remoção é segura quando: (1) os logs e métricas mostram que nenhuma requisição com cabeçalho Origin de navegador chega à API diretamente, por um período que cubra os caches dos clientes; (2) a API não está exposta publicamente para navegadores, idealmente acessível só pela rede interna, pelo site e pelos clientes nativos via gateway. Remover a configuração sem essa verificação é uma mudança incompatível silenciosa, como o exercício 3 de "Produção" discutiu para versões.

Comentários

Mais em Linguagem C#

EF Core: Relacionamentos e Migrations
EF Core: Relacionamentos e Migrations

Autores e pedidos como entidades relacionadas no EF Core: um para muitos com…

SQL Essencial com SQLite: Perguntas a um Banco de Dados
SQL Essencial com SQLite: Perguntas a um Banco de Dados

O modelo relacional e a linguagem SQL na prática, com SQLite: tabelas STRICT…

MongoDB: o Modelo de Documentos e o Driver Oficial
MongoDB: o Modelo de Documentos e o Driver Oficial

O modelo de documentos do MongoDB com o driver oficial para C#: incorporar ou…