[CS] CARLOS SEIDL EN
Artigos

Saga Pattern em .NET: Transações Distribuídas entre Microsserviços

Saga Pattern em .NET 10: transações locais, compensações idempotentes, coreografia e orquestração, com um orquestrador de pedido que compensa a falha.

Por 12 min de leitura
  • Arquitetura
  • Saga
  • Microsserviços
  • .NET 10
Capa do artigo: Saga Pattern em .NET: Transações Distribuídas entre Microsserviços

Saga Pattern: consistência entre microsserviços sem rollback

O problema: uma operação, três bancos, nenhum rollback
O problema: uma operação, três bancos, nenhum rollback

No monólito, "criar pedido" cabia em uma transação: reservar estoque, cobrar e gerar a entrega, tudo com BEGIN e COMMIT. Se algo falhasse, o banco desfazia. Ao dividir em microsserviços (como vimos em Monólito vs Microservices), cada serviço passa a ter seu próprio banco, e essa transação única deixa de existir. Se o estoque foi reservado e o pagamento foi recusado, quem desfaz a reserva?

O Saga Pattern responde a isso sem transação distribuída: quebra a operação em transações locais e, se uma delas falhar, executa ações que desfazem o efeito das anteriores. Neste artigo, que continua a série depois do API Gateway + BFF (que organizou como o cliente chega aos serviços), vemos como os serviços mantêm a consistência entre si.

Conceitos do Saga: transações locais e compensatórias

Saga: uma sequência de transações locais, uma por serviço. Cada transação local confirma seu trabalho no próprio banco e dispara o próximo passo por evento ou mensagem. Não há um COMMIT global.

Transação compensatória: a ação que desfaz o efeito de um passo já confirmado. Ela não é um rollback: o dado já foi gravado, então a compensação é uma nova operação de negócio (liberar a reserva, estornar o pagamento, cancelar o pedido).

Tipos de passo (classificação da documentação da Microsoft):

  • Compensável: pode ser desfeito por uma compensação.
  • Pivô: o ponto sem volta. Depois que ele tem sucesso, os passos seguintes precisam ser concluídos para chegar a um estado final consistente.
  • Retentável: vem depois do pivô, é idempotente e pode ser repetido até dar certo.

Em código, a regra de ordem (compensáveis, no máximo um pivô, depois retentáveis) cabe em poucas linhas, no arquivo StepKinds.cs do repositório:

C#
namespace SagaDemo;





// EN: Order matters: compensable, then the pivot, then retriable.


// PT: A ordem importa: compensáveis, o pivô, depois retentáveis.


public enum StepKind { Compensable, Pivot, Retriable }





public sealed record StepInfo(string Name, StepKind Kind);





public static class SagaPlan


{


    // EN: Valid = kinds never go backwards and there is one pivot


    // at most (after it, there is no way back).


    // PT: Válido = os tipos nunca voltam e há no máximo um pivô


    // (depois dele, não há volta).


    public static bool IsValid(IReadOnlyList<StepInfo> steps) =>


        steps.Count(s => s.Kind == StepKind.Pivot) <= 1


        && steps.Zip(steps.Skip(1))


            .All(p => p.First.Kind <= p.Second.Kind);


}

A ordem dos valores do enum é a própria regra: IsValid exige que o tipo nunca "volte" ao longo dos passos e que exista no máximo um pivô. Um plano que coloca o pivô antes de um passo compensável é recusado, porque esse passo não teria mais como ser desfeito.

Coreografia vs orquestração: as duas formas de coordenar uma saga.

  • Coreografia: não há controlador central. Cada serviço publica eventos e reage aos eventos dos outros.
  • Orquestração: um orquestrador central diz a cada participante o que executar, guarda o estado da saga e dispara as compensações.

Consistência eventual: durante a saga, o sistema pode estar parcialmente atualizado (estoque reservado, pagamento ainda não feito). Ele só volta a ficar consistente quando a saga termina, com sucesso ou compensada.

Regra de bolso: se o fluxo tem poucos serviços e poucos passos, coreografia basta; se tem muitos passos, ramificações ou regras de compensação, prefira orquestração.

Conceitos: transações locais e compensações
Conceitos: transações locais e compensações

Coreografia ou orquestração: arquitetura da Saga

Fluxo de sucesso e de falha de um pedido com três serviços:

Texto
Sucesso:


  Pedido criado → Estoque reservado → Pagamento autorizado


      → Entrega criada → Pedido confirmado





Falha no pagamento:


  Pedido criado → Estoque reservado → Pagamento recusado


                          ↓ compensação (ordem inversa)


                  Reserva liberada → Pedido cancelado

Coreografia: o serviço de Pedido publica PedidoCriado; Estoque reage e publica EstoqueReservado; Pagamento reage e publica PagamentoAutorizado (ou PagamentoRecusado, que Estoque escuta para liberar a reserva). Simples no começo, mas conforme entram passos fica difícil enxergar o fluxo inteiro: segundo a Microsoft, é difícil rastrear a quais comandos cada participante responde, há risco de dependência cíclica e o teste de integração exige todos os serviços no ar.

Orquestração: o orquestrador envia comandos (ReservarEstoque, AutorizarPagamento), recebe as respostas e decide o próximo passo ou a compensação. O fluxo fica em um lugar só e evita dependência cíclica, ao custo de mais um componente que precisa de lógica de coordenação e vira ponto de falha.

A diferença aparece no tipo de mensagem: na orquestração o orquestrador envia um comando (uma ordem a um participante); na coreografia cada serviço publica um evento (um fato que já aconteceu, ao qual qualquer um pode reagir). Em Messages.cs:

C#
namespace SagaDemo;





// EN: Command: an order to ONE participant (orchestration).


// PT: Comando: uma ordem para UM participante (orquestração).


public sealed record ReserveStock(Guid OrderId, decimal Total);





// EN: Event: a fact that already happened; anyone may react


// to it (choreography).


// PT: Evento: um fato que já aconteceu; qualquer um pode reagir


// a ele (coreografia).


public sealed record StockReserved(Guid OrderId);





public static class StockHandler


{


    // EN: The participant receives the command and answers


    // with an event.


    // PT: O participante recebe o comando e responde


    // com um evento.


    public static StockReserved Handle(ReserveStock command) =>


        new(command.OrderId);


}

O participante recebe ReserveStock e responde com StockReserved. Quem decide o próximo passo é o orquestrador (que lê a resposta) ou os outros serviços (que escutam o evento).

Coreografia vs orquestração
Coreografia vs orquestração

Pontos de projeto:

  • Estado da saga persistido: o orquestrador precisa saber em que passo está, mesmo após reinício.
  • Publicação atômica: gravar no banco e publicar a mensagem precisam acontecer juntos. A solução recomendada é o Transactional Outbox (o evento é gravado na mesma transação local e um processo separado o publica).
  • Consumidores idempotentes: brokers como RabbitMQ e Azure Service Bus entregam pelo menos uma vez, então a mesma mensagem pode chegar duas vezes.
  • Correlação: um identificador da saga (aqui, o OrderId) em logs, mensagens e traces.

Em .NET, há duas rotas: usar uma biblioteca de mensageria com suporte a sagas (MassTransit, NServiceBus, Wolverine) ou escrever o orquestrador sobre a sua infraestrutura. O exemplo a seguir mostra a mecânica da orquestração com compensação, sem dependências externas, para o conceito ficar visível.

Saga com orquestrador em C# e .NET, passo a passo

O exemplo é um orquestrador de pedido com três passos: reservar estoque, autorizar pagamento e criar entrega. Os serviços são simulados em memória; num sistema real, cada ISagaStep chamaria outro microsserviço por HTTP ou mensagem. Os blocos são os arquivos do repositório, sem cortes; por isso as mensagens de log e os comentários aparecem em inglês e português.

Código completo, com README e instruções para rodar: github.com/ceseidl/saga-pattern-dotnet

4.1. Contrato do passo

Arquivo Saga.cs.

C#
namespace SagaDemo;





public sealed record OrderContext(


    Guid OrderId, string CustomerId, decimal Total);





public interface ISagaStep


{


    string Name { get; }





    Task ExecuteAsync(OrderContext ctx, CancellationToken ct);





    Task CompensateAsync(OrderContext ctx, CancellationToken ct);


}





public enum SagaOutcome


{


    Completed,


    Compensated,


    CompensationFailed


}

4.2. Orquestrador

Arquivo OrderSagaOrchestrator.cs.

C#
namespace SagaDemo;





public sealed class OrderSagaOrchestrator(


    IReadOnlyList<ISagaStep> steps,


    ILogger<OrderSagaOrchestrator> logger)


{


    private const int MaxCompensationAttempts = 3;





    public async Task<SagaOutcome> RunAsync(


        OrderContext ctx, CancellationToken ct = default)


    {


        var completed = new Stack<ISagaStep>();





        foreach (var step in steps)


        {


            try


            {


                logger.LogInformation(


                    "[{Order}] executing / executando {Step}",


                    ctx.OrderId, step.Name);


                await step.ExecuteAsync(ctx, ct);


                completed.Push(step);


            }


            catch (Exception ex)


                when (ex is not OperationCanceledException)


            {


                logger.LogWarning(ex,


                    "[{Order}] {Step} failed; starting "


                    + "compensation / falhou; iniciando "


                    + "compensação",


                    ctx.OrderId, step.Name);


                return await CompensateAsync(ctx, completed, ct);


            }


        }





        return SagaOutcome.Completed;


    }





    private async Task<SagaOutcome> CompensateAsync(


        OrderContext ctx, Stack<ISagaStep> completed,


        CancellationToken ct)


    {


        var allOk = true;





        // EN: Reverse order: the last completed step is


        // undone first.


        // PT: Ordem inversa: o último passo concluído é


        // desfeito primeiro.


        while (completed.TryPop(out var step))


        {


            var ok = false;


            for (var attempt = 1;


                 attempt <= MaxCompensationAttempts && !ok;


                 attempt++)


            {


                try


                {


                    // EN: Must be idempotent.


                    // PT: Precisa ser idempotente.


                    await step.CompensateAsync(ctx, ct);


                    ok = true;


                    logger.LogInformation(


                        "[{Order}] compensated / "


                        + "compensado {Step}",


                        ctx.OrderId, step.Name);


                }


                catch (Exception ex)


                    when (ex is not OperationCanceledException)


                {


                    logger.LogWarning(ex,


                        "[{Order}] compensation of {Step} "


                        + "failed / compensação falhou "


                        + "({Attempt}/{Max})",


                        ctx.OrderId, step.Name, attempt,


                        MaxCompensationAttempts);


                    await Task.Delay(


                        TimeSpan.FromMilliseconds(100 * attempt),


                        ct);


                }


            }





            if (!ok)


            {


                allOk = false;


                logger.LogError(


                    "[{Order}] compensation of {Step} exhausted "


                    + "retries: manual intervention / "


                    + "compensação esgotou tentativas: "


                    + "intervenção manual",


                    ctx.OrderId, step.Name);


            }


        }





        return allOk


            ? SagaOutcome.Compensated


            : SagaOutcome.CompensationFailed;


    }


}
Orquestrador: passos em ordem e compensação em ordem inversa
Orquestrador: passos em ordem e compensação em ordem inversa

Em destaque, o laço de execução: só o passo concluído entra na pilha, e a primeira falha dispara a compensação.

Trecho em destaque: laço de execução
Trecho em destaque: laço de execução

E o laço de compensação, que desempilha em ordem inversa e repete cada compensação até 3 vezes:

Trecho em destaque: laço de compensação
Trecho em destaque: laço de compensação

4.3. Passos com compensação idempotente

Arquivo Steps.cs.

C#
using System.Collections.Concurrent;





namespace SagaDemo;





// EN: Each step would call another microservice (HTTP or


// message). Here they run in memory.


// PT: Cada passo chamaria outro microsserviço (HTTP ou


// mensagem). Aqui rodam em memória.


// EN: The idempotency key is the OrderId.


// PT: A chave de idempotência é o OrderId.


public sealed class ReserveStockStep : ISagaStep


{


    private readonly ConcurrentDictionary<Guid, bool>


        _reservations = new();





    public string Name => "ReservarEstoque";





    public Task ExecuteAsync(


        OrderContext ctx, CancellationToken ct)


    {


        // EN: Upsert: repeating does not duplicate.


        // PT: Upsert: repetir não duplica.


        _reservations[ctx.OrderId] = true;


        return Task.CompletedTask;


    }





    public Task CompensateAsync(


        OrderContext ctx, CancellationToken ct)


    {


        // EN: Removing what does not exist is a no-op.


        // PT: Remover o que não existe é no-op.


        _reservations.TryRemove(ctx.OrderId, out _);


        return Task.CompletedTask;


    }


}





public sealed class AuthorizePaymentStep(bool failOnExecute)


    : ISagaStep


{


    private readonly ConcurrentDictionary<Guid, decimal>


        _authorizations = new();





    public string Name => "AutorizarPagamento";





    public Task ExecuteAsync(


        OrderContext ctx, CancellationToken ct)


    {


        if (failOnExecute)


            throw new InvalidOperationException(


                "Card declined / Cartão recusado");


        // EN: Does not charge the same order twice.


        // PT: Não cobra duas vezes o mesmo pedido.


        _authorizations.TryAdd(ctx.OrderId, ctx.Total);


        return Task.CompletedTask;


    }





    public Task CompensateAsync(


        OrderContext ctx, CancellationToken ct)


    {


        // EN: Cancel the authorization.


        // PT: Cancelar a autorização.


        _authorizations.TryRemove(ctx.OrderId, out _);


        return Task.CompletedTask;


    }


}





public sealed class CreateShipmentStep : ISagaStep


{


    public string Name => "CriarEntrega";





    public Task ExecuteAsync(


        OrderContext ctx, CancellationToken ct)


        => Task.CompletedTask;





    public Task CompensateAsync(


        OrderContext ctx, CancellationToken ct)


        => Task.CompletedTask;


}

CreateShipmentStep segue o mesmo contrato, com execução e compensação vazias no exemplo.

O que torna os passos seguros para repetição, em destaque:

Trecho em destaque: execução e compensação idempotentes
Trecho em destaque: execução e compensação idempotentes

4.4. Execução

Arquivo Program.cs: antes das sagas, ele valida dois planos com SagaPlan e mostra o comando virando evento; depois roda o fluxo feliz, o de falha e a mensagem duplicada.

C#
using SagaDemo;





var services = new ServiceCollection();


services.AddLogging(b => b


    .AddSimpleConsole(o => o.SingleLine = true)


    .SetMinimumLevel(LogLevel.Information));


var provider = services.BuildServiceProvider();


var logger = provider


    .GetRequiredService<ILogger<OrderSagaOrchestrator>>();





async Task<SagaOutcome> Run(bool paymentFails)


{


    ISagaStep[] steps =


    [


        new ReserveStockStep(),


        new AuthorizePaymentStep(failOnExecute: paymentFails),


        new CreateShipmentStep()


    ];


    var orchestrator = new OrderSagaOrchestrator(steps, logger);


    var order = new OrderContext(


        Guid.NewGuid(), "cli-42", 199.90m);


    return await orchestrator.RunAsync(order);


}





StepInfo[] valid =


[


    new("ReservarEstoque", StepKind.Compensable),


    new("CobrarCartao", StepKind.Pivot),


    new("EmitirNota", StepKind.Retriable)


];


StepInfo[] invalid = [valid[1], valid[0]];


Console.WriteLine($"Plan / Plano: {SagaPlan.IsValid(valid)}");


Console.WriteLine($"Pivot first / Pivô primeiro: "


    + $"{SagaPlan.IsValid(invalid)}");





var command = new ReserveStock(Guid.NewGuid(), 10m);


var evt = StockHandler.Handle(command);


Console.WriteLine($"Command / Comando: "


    + $"{command.GetType().Name} -> {evt.GetType().Name}");





Console.WriteLine($"Happy path / Fluxo feliz -> "


    + $"{await Run(paymentFails: false)}");


Console.WriteLine($"Failure path / Fluxo de falha -> "


    + $"{await Run(paymentFails: true)}");





var inbox = new InboxStore();


var consumer = new StockConsumer(inbox, new ReserveStockStep());


var msg = new SagaMessage(


    Guid.NewGuid(), Guid.NewGuid(), "ReservarEstoque");


var octx = new OrderContext(Guid.NewGuid(), "cli-42", 10m);


await consumer.HandleAsync(msg, octx, CancellationToken.None);


// EN: Redelivery of the same message.


// PT: Reentrega da mesma mensagem.


await consumer.HandleAsync(msg, octx, CancellationToken.None);


var ignored = !inbox.TryClaim("estoque", msg.MessageId);


Console.WriteLine($"Inbox: second delivery ignored / "


    + $"segunda entrega ignorada = {ignored}");

Saída resumida:

Texto
Plan / Plano: True


Pivot first / Pivô primeiro: False


Command / Comando: ReserveStock -> StockReserved


Happy path / Fluxo feliz -> Completed


... AutorizarPagamento failed; starting compensation ...


... compensated / compensado ReservarEstoque


Failure path / Fluxo de falha -> Compensated


Inbox: second delivery ignored / segunda entrega ignorada = True

Pontos importantes do código

  • O orquestrador empilha cada passo concluído. Quando um passo falha, ele desempilha: o último concluído é o primeiro desfeito.
  • Apenas os passos já concluídos são compensados. O passo que falhou não entra na pilha.
  • A compensação tem retentativas porque ela mesma pode falhar. Se esgotar, o resultado é CompensationFailed e alguém precisa agir (alerta, fila de intervenção).
  • Cada passo usa o OrderId como chave de idempotência: executar ou compensar duas vezes tem o mesmo efeito de uma.
  • OperationCanceledException não dispara compensação automática neste exemplo; em produção, decida essa regra explicitamente.
  • Este exemplo não persiste o estado da saga. Se o processo cair no meio, o estado se perde. Em produção, grave o passo atual e os já concluídos a cada transição.
  • O código usa ILogger e a injeção de dependência do shared framework do ASP.NET Core; em um projeto de console puro, adicione o pacote Microsoft.Extensions.Hosting.

4.5. Com biblioteca: o esqueleto de uma state machine

Com MassTransit, a orquestração é modelada como máquina de estados: uma classe de estado persistida e uma classe que descreve estados e eventos. O esqueleto base, conforme a documentação:

C#
public class OrderState : SagaStateMachineInstance


{


    public Guid CorrelationId { get; set; }


    public string CurrentState { get; set; }


}





public class OrderStateMachine


    : MassTransitStateMachine<OrderState>


{


    public State Submitted { get; private set; } = null!;


    public State Accepted { get; private set; } = null!;





    public OrderStateMachine()


    {


        InstanceState(x => x.CurrentState);


    }


}

O estado (CurrentState) fica no repositório da saga, o que resolve a persistência que o exemplo anterior deixou de fora. Os eventos e o comportamento por estado são declarados em seguida, conforme o guia oficial. Antes de adotar, confira a licença: segundo a documentação oficial, o MassTransit v9 é um produto comercial.

Saga persistida: estados, eventos e compensação
Saga persistida: estados, eventos e compensação

Boas práticas e erros comuns ao implementar Saga

Boas práticas e pontos de atenção
Boas práticas e pontos de atenção

Erros comuns

  • Compensação que não é idempotente: ela pode ser executada mais de uma vez (retentativa, redelivery) e estornar duas vezes.
  • Tratar compensação como rollback: ela é uma nova operação de negócio e pode ser parcial (cancelar uma passagem pode não devolver 100% do valor).
  • Gravar no banco e publicar a mensagem em dois passos separados: um crash entre os dois deixa o sistema inconsistente. Use Outbox.
  • Ignorar a falta de isolamento: sagas concorrentes enxergam dados intermediários, e isso gera leituras sujas e atualizações perdidas.
  • Saga sem monitoramento: sem saber em que passo cada saga parou, não há como achar as travadas.
  • Compensação sem tratamento de falha: quando ela esgota as tentativas, precisa haver alerta e fila de intervenção manual.

Dicas de produção

  • Persista o estado da saga a cada transição e correlacione tudo por um id (logs, mensagens, traces com OpenTelemetry).
  • Use Outbox na publicação e consumidor idempotente (por exemplo, uma tabela de mensagens processadas com restrição de unicidade) na recepção. Não substitua a idempotência do consumidor pela deduplicação do broker.
  • Defina timeouts por passo: um passo pode ficar bloqueado sem falhar.
  • Ordene os passos para deixar o que não tem volta (o pivô) o mais tarde possível, depois das validações.
  • Contramedidas de isolamento, segundo a Microsoft: semantic lock (marcar o registro como "em processamento"), atualizações comutativas, reler valores antes de atualizar e visão pessimista (reordenar passos).
  • Nem toda falha pede compensação: retente falhas transitórias antes e só compense quando não houver como avançar.
  • Para decisões de alto impacto ou ambíguas, considere pausar a saga para revisão humana.

Exemplo: consumidor idempotente (Inbox)

Uma mensagem pode chegar duas vezes. O consumidor registra a chave da mensagem e ignora a repetição. Aqui em memória, para mostrar a ideia; em produção, a chave vai para uma tabela com restrição de unicidade, gravada na mesma transação do efeito de negócio. Arquivo Inbox.cs:

C#
using System.Collections.Concurrent;





namespace SagaDemo;





using MessageKey = (string Consumer, Guid MessageId);





public sealed record SagaMessage(


    Guid MessageId, Guid SagaId, string Type);





public sealed class InboxStore


{


    private readonly ConcurrentDictionary<MessageKey, bool>


        _processed = new();





    // EN: TryAdd is atomic: only one caller wins the message key.


    // PT: TryAdd é atômico: só um chamador "ganha" a chave.


    public bool TryClaim(string consumer, Guid messageId) =>


        _processed.TryAdd((consumer, messageId), true);


}





public sealed class StockConsumer(


    InboxStore inbox, ReserveStockStep reserve)


{


    public async Task HandleAsync(


        SagaMessage message,


        OrderContext ctx,


        CancellationToken ct)


    {


        if (!inbox.TryClaim("estoque", message.MessageId))


            // EN: Duplicate: acknowledge and ignore it.


            // PT: Duplicada: confirma a mensagem e ignora.


            return;





        await reserve.ExecuteAsync(ctx, ct);


    }


}
Trecho em destaque: chave da mensagem e descarte de duplicada
Trecho em destaque: chave da mensagem e descarte de duplicada

Chave por consumidor e por mensagem (e não só pela mensagem), para que vários consumidores do mesmo evento não se bloqueiem entre si, como recomenda a documentação da Microsoft.

Quando usar Saga e quando evitar

Quando usar e quando evitar
Quando usar e quando evitar

Use quando

  • Uma operação de negócio passa por vários serviços, cada um com seu banco.
  • Você precisa de consistência sem acoplar os serviços por uma transação distribuída.
  • Dá para definir uma compensação de negócio para cada passo.

Evite (ou adie) quando

  • Tudo roda em um serviço e um banco: uma transação local resolve.
  • O fluxo tem poucos passos: a coordenação custa mais do que o problema.
  • Exige consistência forte e imediata: a saga é eventualmente consistente por natureza (a Microsoft também aponta transações fortemente acopladas e compensação não confiável como contraindicações).
  • Há ações que não podem ser desfeitas (efeitos externos, atos com valor legal) e não existe compensação possível. Elas só podem ficar depois do pivô.
  • O sistema é um CRUD simples e a saga só adiciona complexidade.
  • O problema se resolve com retentativa e as falhas são quase todas transitórias: compensação só acrescenta complexidade.
  • O time ainda está no monólito: antes de uma saga, revise se a divisão em serviços é mesmo necessária.

Alternativas

  • Transação local no monólito modular: a opção mais simples enquanto os módulos compartilham o banco.
  • Two-phase commit / transação distribuída: dá consistência forte, mas acopla os serviços; o guia de microsserviços da Microsoft não recomenda transações distribuídas com mensageria, pelas razões do teorema CAP, e prioriza disponibilidade e tolerância a partição.
  • Retry + idempotência: quando só falhas transitórias importam.
  • Workflows duráveis (por exemplo, Durable Task / Durable Functions) quando o fluxo é longo e precisa de estado persistido gerenciado.

Saga em um olhar: pilares e compensações

Conclusão: saga em um olhar
Conclusão: saga em um olhar

A saga troca o COMMIT global por transações locais mais compensações. Coreografia serve a fluxos curtos; orquestração, a fluxos com mais passos e regras. Em qualquer caso, o que sustenta a saga em produção são os mesmos pilares: estado persistido, Outbox, consumidores idempotentes, compensações idempotentes e observabilidade.

Reflexão prática: antes de adotar, liste os passos do seu fluxo mais crítico e escreva, para cada um, qual é a compensação e se ela pode falhar. Se algum passo não tiver compensação possível, ele é o seu pivô e deve ficar no fim da sequência. Se você não consegue escrever as compensações, talvez esses dados devam estar no mesmo serviço.

Referências e código do Saga Pattern

Leia também

Quer uma revisão da arquitetura do seu backend .NET? Conheça o serviço de backend e arquitetura .NET.

← todos os artigos