Criando uma API do zero com .NET Core 3.1 e EF Core

Criar uma API com EF Core e .NET Core 3.1 do zero é uma ótima forma de entender, na prática, como as peças do ecossistema .NET se encaixam. Neste tutorial, vamos montar o projeto passo a passo, passando por entidades, commands, handlers, repositórios, banco em memória, testes e Swagger.

O objetivo não é apenas fazer funcionar. Na verdade, a ideia é organizar a solução de um jeito que permita escalar no futuro sem grandes problemas.

Este tutorial usa o .NET Core 3.1 e o Entity Framework Core 3.1, exatamente como o projeto original. Vale lembrar que o suporte oficial a essa versão terminou em dezembro de 2022, então os conceitos continuam valendo, mas projetos novos devem usar uma versão suportada.

O que vamos construir

Nossa API com EF Core será um gerador de números de cartão de crédito virtual. Para isso, a API terá dois endpoints:

  • Um endpoint recebe o e-mail da pessoa e retorna um objeto com o número do cartão virtual gerado.
  • Outro endpoint lista, em ordem de criação, todos os cartões virtuais de um solicitante, recebendo o e-mail como parâmetro.

Os números gerados são fictícios e servem apenas para fins didáticos. Ou seja, eles não seguem nenhuma regra real de emissão de cartões.

Primeiro, crie uma pasta para o projeto. Neste exemplo, a pasta se chama VirtualCardApi e guarda todos os arquivos da solução.

Em seguida, acesse a pasta da API com EF Core pelo terminal de sua preferência. Nossa API será pequena, com poucos arquivos, mas mesmo assim vamos criar uma estrutura preparada para crescer.

Passo 1: estruturando a API com EF Core

Com o terminal aberto na pasta, rode os comandos abaixo para criar os projetos:

dotnet new classlib -o Domain
dotnet new classlib -o Infra
dotnet new webapi -o Api
dotnet new classlib -o Shared
dotnet new mstest -o Tests

Com esses cinco comandos, criamos os seguintes projetos:

  • Domain: guarda as regras de negócio, as entidades e os objetos de valor.
  • Infra: concentra a infraestrutura, incluindo o acesso ao banco de dados.
  • Api: expõe os endpoints e consome os outros projetos.
  • Shared: compartilha código entre projetos quando a aplicação crescer.
  • Tests: reúne os testes de unidade.

Agora vamos criar uma solution e associar os projetos a ela:

dotnet new sln
dotnet sln add ./Domain/Domain.csproj
dotnet sln add ./Infra/Infra.csproj
dotnet sln add ./Api/Api.csproj
dotnet sln add ./Shared/Shared.csproj
dotnet sln add ./Tests/Tests.csproj

Também precisamos dizer quem depende de quem. Por isso, adicione as referências entre os projetos:

# o domínio usa contratos compartilhados
dotnet add Domain reference Shared
# a infraestrutura implementa contratos do domínio
dotnet add Infra reference Domain
# a API conhece todas as camadas
dotnet add Api reference Domain Infra Shared
# os testes exercitam o domínio
dotnet add Tests reference Domain Shared

Repare que, nessa API com EF Core, o domínio não depende da infraestrutura. Dessa forma, as regras de negócio ficam isoladas dos detalhes de banco de dados.

Passo 2: criando a entidade da API com EF Core

Antes de começar, apague as classes que vêm por padrão nos templates. Então remova o WeatherForecast.cs e o controller de exemplo do projeto Api, além do Class1.cs dos projetos Domain, Infra e Shared.

De acordo com os requisitos, nossa entidade principal precisa de uma propriedade para o e-mail, outra para o número do cartão e outra para a data de geração. Assim, crie uma pasta Entities dentro de Domain e, nela, o arquivo VirtualCard.cs.

using System;
using System.ComponentModel.DataAnnotations;

namespace Domain.Entities
{
    public class VirtualCard
    {
        // construtor obriga a informar os três dados
        public VirtualCard(string email, string cardNumber, DateTime createdAt)
        {
            Email = email;
            CardNumber = cardNumber;
            CreatedAt = createdAt;
        }

        [Key]
        public int Id { get; private set; }

        public string Email { get; private set; }

        [MaxLength(16, ErrorMessage = "This field must have at most 16 characters")]
        public string CardNumber { get; private set; }

        public DateTime CreatedAt { get; private set; }
    }
}

Os setters estão privados para evitar que as propriedades sejam alteradas de fora da classe. Com isso, protegemos o encapsulamento da entidade.

Além disso, como e-mail, número e data são obrigatórios, o construtor recebe os três valores. Portanto, não existe forma de criar um cartão virtual sem essas informações.

Passo 3: criando os commands

Em uma API com EF Core, não é recomendado usar a entidade para receber dados da requisição e devolver resultados. Por isso, criaremos dois commands, que você também pode chamar de DTO ou Model.

Um command serve de entrada, trazendo o e-mail necessário para gerar o cartão. O outro serve de resultado, devolvendo os dados após a geração.

Estrutura de pastas da API com EF Core mostrando os commands de entrada e de resultado dentro do projeto Domain
Organização das pastas de commands no projeto Domain.

Para validar o e-mail, vamos usar o Flunt, um pacote de notificações e validações. Se você ainda não o conhece, o artigo sobre o Flunt explica o conceito em detalhes.

cd ./Domain
dotnet add package Flunt --version 1.0.5

Depois, crie o command de entrada herdando de Notifiable:

using Flunt.Notifications;
using Flunt.Validations;

namespace Domain.Commands.Inputs
{
    public class GenerateVirtualCardCommand : Notifiable
    {
        public string Email { get; set; }

        public void Validate()
        {
            // adiciona uma notificação se o e-mail for inválido
            AddNotifications(new Contract()
                .IsEmail(Email, "Email", "Invalid email"));
        }
    }
}

Em seguida, crie o command de resultado:

namespace Domain.Commands.Results
{
    public class GenerateVirtualCardCommandResult
    {
        public GenerateVirtualCardCommandResult(string email, string cardNumber)
        {
            Email = email;
            CardNumber = cardNumber;
        }

        public string Email { get; set; }
        public string CardNumber { get; set; }
    }
}

Passo 4: criando o handler

Os handlers executam as regras de negócio da API com EF Core. No nosso caso, teremos somente um handler, responsável pela geração do número do cartão virtual.

Assim como antes, crie uma pasta Handlers em Domain. Dentro dela, crie o arquivo GenerateVirtualCardHandler.cs.

Pasta Handlers criada no projeto Domain com o arquivo do handler de geração de cartão
O handler fica na pasta Handlers, dentro do domínio.

Nesse arquivo, vamos realizar os seguintes passos:

  • Validar o command enviado pela requisição.
  • Gerar o número do cartão.
  • Criar a entidade e salvá-la no banco.
  • Criar o command result e retornar o resultado.

Uma dica útil: depois de criar a classe, escreva esses passos como comentários antes de implementar. Assim, a implementação fica mais direcionada na sua cabeça.

Classe do handler com os passos da implementação descritos em comentários
Comentar os passos antes de codificar ajuda a guiar a implementação.

Passo 5: criando os testes antes da implementação

Também é recomendado criar os testes antes de desenvolver as funcionalidades da API com EF Core. Então vamos escrever os testes do handler antes de implementá-lo.

Neste ponto, abrir o projeto no Visual Studio facilita a criação e a execução dos testes. No projeto Tests, crie a pasta HandlerTests e adicione a classe abaixo:

using Domain.Commands.Inputs;
using Domain.Handlers;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Tests.HandlerTests
{
    [TestClass]
    public class GenerateVirtualCardHandlerTests
    {
        [TestMethod]
        public void ShouldReturnNullWhenEmailIsInvalid()
        {
            var command = new GenerateVirtualCardCommand { Email = "john.doe.com" };

            var handler = new GenerateVirtualCardHandler();
            var result = handler.Handle(command);

            Assert.IsNull(result);
        }

        [TestMethod]
        public void ShouldReturnResultWhenEmailIsValid()
        {
            var command = new GenerateVirtualCardCommand { Email = "john@doe.com" };

            var handler = new GenerateVirtualCardHandler();
            var result = handler.Handle(command);

            Assert.IsNotNull(result);
        }
    }
}

Provavelmente os testes vão falhar, ou nem compilar, neste momento. Mesmo assim, tê-los criado já orienta bastante o desenvolvimento do handler.

Se você quiser se aprofundar no assunto, o artigo sobre fundamentos dos testes de unidade cobre os conceitos básicos.

Passo 6: implementando o handler

Seguindo os comentários, vamos implementar o handler da seguinte forma:

using System;
using Domain.Commands.Inputs;
using Domain.Commands.Results;
using Domain.Entities;

namespace Domain.Handlers
{
    public class GenerateVirtualCardHandler
    {
        public GenerateVirtualCardCommandResult Handle(GenerateVirtualCardCommand command)
        {
            // validar o command
            command.Validate();
            if (command.Invalid)
                return null;

            // gerar um número de cartão aleatório com 4 blocos de 4 dígitos
            var random = new Random();
            var cardNumber = string.Empty;
            for (var i = 0; i < 4; i++)
                cardNumber += random.Next(1000, 9999).ToString();

            // criar a entidade (o salvamento vem no próximo passo)
            var virtualCard = new VirtualCard(command.Email, cardNumber, DateTime.Now);

            // criar o command result
            return new GenerateVirtualCardCommandResult(command.Email, cardNumber);
        }
    }
}

Você deve ter percebido que a API com EF Core ainda não salva nada no banco. De fato, o importante agora é deixar o domínio pronto antes de configurar a persistência.

Rode os testes novamente e confira se a implementação está correta. Se estiver, siga em frente, e se não estiver, ajuste o handler até os testes passarem.

Passo 7: criando o repositório da API com EF Core

Chegou a hora de criar o repositório que conecta a API com EF Core ao banco. Em Domain, crie a pasta Repositories e, dentro dela, a interface IVirtualCardRepository.

using System.Collections.Generic;
using Domain.Entities;

namespace Domain.Repositories
{
    public interface IVirtualCardRepository
    {
        void Add(VirtualCard virtualCard);
        IEnumerable<VirtualCard> ListByEmail(string email);
    }
}

Criamos essa interface para depender sempre de uma abstração, e nunca da implementação. Esse é o princípio da inversão de dependência, um dos pilares do SOLID.

O domínio define o contrato, enquanto a infraestrutura cuida dos detalhes. Para entender melhor esse padrão, vale ler sobre o repository pattern em aplicações .NET.

Passo 8: banco em memória na API com EF Core

Neste exemplo, nossa API com EF Core usa um banco de dados em memória para facilitar o desenvolvimento. Portanto, não precisamos instalar nenhum servidor de banco.

No projeto Infra, instale o pacote Microsoft.EntityFrameworkCore.InMemory em uma versão 3.1. Pelo Visual Studio, clique com o botão direito no projeto e escolha Manage NuGet Packages.

Menu de contexto do projeto Infra no Visual Studio com a opção Manage NuGet Packages
Acesso ao gerenciador de pacotes NuGet pelo menu do projeto.

Depois, pesquise pelo nome do pacote e faça a instalação.

Gerenciador NuGet do Visual Studio instalando o pacote InMemory para a API com EF Core
Instalação do provedor em memória do Entity Framework Core.

Se preferir o terminal, o comando equivalente é este:

cd ./Infra
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 3.1.32

Criando o DataContext

O DataContext é a classe da API com EF Core responsável pela comunicação com o banco de dados. Como usamos o provedor em memória, os dados ficam salvos apenas enquanto a aplicação estiver rodando.

using Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace Infra
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> options)
            : base(options)
        {
        }

        // cada DbSet representa uma tabela mapeada
        public DbSet<VirtualCard> VirtualCards { get; set; }
    }
}

O construtor segue o padrão que você vai usar quase sempre. Já a propriedade DbSet<> aparece para cada entidade mapeada, e o Entity Framework cuida da tradução entre o banco e os objetos.

Implementando o repositório

Agora implemente a interface do domínio no projeto Infra:

using System.Collections.Generic;
using System.Linq;
using Domain.Entities;
using Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Infra.Repositories
{
    public class VirtualCardRepository : IVirtualCardRepository
    {
        private readonly DataContext _context;

        // o container injeta o contexto automaticamente
        public VirtualCardRepository(DataContext context)
        {
            _context = context;
        }

        public void Add(VirtualCard virtualCard)
        {
            _context.VirtualCards.Add(virtualCard);
            _context.SaveChanges();
        }

        public IEnumerable<VirtualCard> ListByEmail(string email)
        {
            // AsNoTracking evita rastrear entidades só de leitura
            return _context.VirtualCards
                .AsNoTracking()
                .Where(x => x.Email == email)
                .OrderBy(x => x.CreatedAt)
                .ToList();
        }
    }
}

O repositório recebe o DataContext pelo construtor, ou seja, usa injeção de dependência. Em outras palavras, a classe recebe de fora um objeto que sabe fazer algo que ela sozinha não sabe.

Registrando os serviços

Para a injeção de dependência da API com EF Core funcionar, registre os serviços no método ConfigureServices do Startup.cs, no projeto Api:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    // DbContext com banco em memória, criado uma vez por requisição
    services.AddDbContext<DataContext>(options =>
        options.UseInMemoryDatabase("VirtualCards"));

    // nova instância do repositório a cada injeção
    services.AddTransient<IVirtualCardRepository, VirtualCardRepository>();
}

O AddDbContext registra o contexto com tempo de vida scoped. Assim, a aplicação abre uma única instância do contexto por requisição do usuário.

Já o AddTransient cria um novo repositório sempre que ele for injetado em uma classe. Se quiser revisar esses tempos de vida, o artigo sobre injeção de dependência no ASP.NET Core explica cada um.

Vale um alerta importante. Segundo a documentação do provedor em memória, ele não foi feito para produção e não oferece transações nem SQL puro.

Passo 9: criando o controller da API com EF Core

No projeto Api, crie a pasta Controllers, se ela não existir. Em seguida, adicione o arquivo VirtualCardController.cs:

using System.Collections.Generic;
using Domain.Commands.Inputs;
using Domain.Entities;
using Domain.Handlers;
using Domain.Repositories;
using Microsoft.AspNetCore.Mvc;
using Shared.Commands;

namespace Api.Controllers
{
    [ApiController]
    [Route("v1/cards")]
    public class VirtualCardController : ControllerBase
    {
        private readonly IVirtualCardRepository _repository;

        public VirtualCardController(IVirtualCardRepository repository)
        {
            _repository = repository;
        }

        [HttpGet("{email}")]
        public IEnumerable<VirtualCard> Get(string email)
        {
            // lista os cartões do e-mail informado
            return _repository.ListByEmail(email);
        }

        [HttpPost("")]
        public ICommandResult Post([FromBody] GenerateVirtualCardCommand command)
        {
            // delega a regra de negócio ao handler
            var handler = new GenerateVirtualCardHandler(_repository);
            return handler.Handle(command);
        }
    }
}

Percebeu a injeção de dependência aqui também? O controller recebe o repositório pelo construtor e o repassa ao handler.

O atributo [Route] antes da classe define a rota padrão. Por exemplo, tudo que chega a esse controller começa com v1/cards, e uma sub-rota test resultaria em v1/cards/test.

O método Get não tem segredo, porque usa o repositório para trazer os cartões do e-mail recebido. Por outro lado, o método Post chama o handler e retorna um ICommandResult, que vamos criar agora na refatoração.

Passo 10: refatorando o resultado

Chegou a hora de usar o projeto Shared da API com EF Core. Ele serve para compartilhar recursos comuns entre projetos e se torna mais importante à medida que a aplicação cresce.

Dentro de Shared, crie a interface ICommandResult. Ela será a base dos retornos das requisições POST, PUT e DELETE.

namespace Shared.Commands
{
    public interface ICommandResult
    {
        bool Success { get; set; }
        string Message { get; set; }
        object Data { get; set; }
    }
}

Essas três propriedades sempre voltam para o front end. Apenas Data muda de acordo com a requisição.

Com isso, o antigo command result vira uma classe genérica chamada CommandResult:

using Shared.Commands;

namespace Domain.Commands.Results
{
    public class CommandResult : ICommandResult
    {
        public CommandResult(bool success, string message, object data)
        {
            Success = success;
            Message = message;
            Data = data;
        }

        public bool Success { get; set; }
        public string Message { get; set; }
        public object Data { get; set; }
    }
}

Handler com repositório e CommandResult

O handler também muda. Agora ele recebe o repositório, salva o cartão e devolve sempre um ICommandResult:

using System;
using Domain.Commands.Inputs;
using Domain.Commands.Results;
using Domain.Entities;
using Domain.Repositories;
using Shared.Commands;

namespace Domain.Handlers
{
    public class GenerateVirtualCardHandler
    {
        private readonly IVirtualCardRepository _repository;

        public GenerateVirtualCardHandler(IVirtualCardRepository repository)
        {
            _repository = repository;
        }

        public ICommandResult Handle(GenerateVirtualCardCommand command)
        {
            // validar o command
            command.Validate();
            if (command.Invalid)
                return new CommandResult(false, "Invalid email", command.Notifications);

            // gerar um número de cartão aleatório
            var random = new Random();
            var cardNumber = string.Empty;
            for (var i = 0; i < 4; i++)
                cardNumber += random.Next(1000, 9999).ToString();

            // criar a entidade e salvar no banco
            var virtualCard = new VirtualCard(command.Email, cardNumber, DateTime.Now);
            try
            {
                _repository.Add(virtualCard);
            }
            catch (Exception ex)
            {
                return new CommandResult(false, "Error while saving data", ex.Message);
            }

            // criar o command result e retornar
            return new CommandResult(true, "Request completed successfully",
                new { command.Email, cardNumber });
        }
    }
}

Além de melhorar o retorno quando o command é inválido, o handler chama o repositório dentro de um try catch. Dessa forma, um erro de persistência vira uma resposta clara, em vez de uma exceção sem tratamento.

Testes com repositório falso

Se você rodar os testes da API com EF Core agora, vai perceber que todos quebraram. Então vamos refatorá-los também, começando por um repositório falso no projeto Tests:

using System.Collections.Generic;
using Domain.Entities;
using Domain.Repositories;

namespace Tests.Repositories
{
    public class FakeVirtualCardRepository : IVirtualCardRepository
    {
        public void Add(VirtualCard virtualCard)
        {
            // método intencionalmente vazio
        }

        public IEnumerable<VirtualCard> ListByEmail(string email)
        {
            return new List<VirtualCard>();
        }
    }
}

Essa é uma das grandes vantagens de trabalhar com interfaces. Afinal, na hora de testar, não podemos depender de serviços externos como o banco.

using Domain.Commands.Inputs;
using Domain.Handlers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Tests.Repositories;

namespace Tests.HandlerTests
{
    [TestClass]
    public class GenerateVirtualCardHandlerTests
    {
        [TestMethod]
        public void ShouldReturnInvalidEmailMessage()
        {
            var command = new GenerateVirtualCardCommand { Email = "john.doe.com" };
            var handler = new GenerateVirtualCardHandler(new FakeVirtualCardRepository());

            var result = handler.Handle(command);

            Assert.AreEqual("Invalid email", result.Message);
        }

        [TestMethod]
        public void ShouldReturnSuccess()
        {
            var command = new GenerateVirtualCardCommand { Email = "john@doe.com" };
            var handler = new GenerateVirtualCardHandler(new FakeVirtualCardRepository());

            var result = handler.Handle(command);

            Assert.IsTrue(result.Success);
        }
    }
}

Documentando a API com EF Core no Swagger

Agora a API deve estar funcionando. Primeiro, rode os testes e, se tudo passar, vamos testar as requisições.

Você pode usar o Postman ou o Swagger, que oferece uma interface gráfica amigável. Neste tutorial, escolhemos o Swagger.

No projeto Api, adicione o pacote Swashbuckle.AspNetCore, que é o pacote do Swagger para o ASP.NET Core:

cd ./Api
dotnet add package Swashbuckle.AspNetCore --version 5.6.3

Em seguida, adicione estas linhas ao ConfigureServices:

services.AddSwaggerGen(options =>
{
    // requer using Microsoft.OpenApi.Models
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "Virtual Card API", Version = "v1" });
});

Por fim, no método Configure, habilite o middleware e a interface:

app.UseSwagger();
app.UseSwaggerUI(options =>
{
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "Virtual Card API v1");
});

Para conhecer alternativas mais recentes de documentação, veja o artigo sobre OpenAPI, Swagger e Scalar no .NET.

Testando as requisições da API com EF Core

Com a aplicação rodando, acesse https://localhost:5001/swagger/index.html. A tela lista os dois endpoints do controller.

Interface do Swagger listando os endpoints GET e POST da API com EF Core
Página do Swagger com os endpoints do controller de cartões.

Primeiro, teste o POST com um e-mail inválido. A resposta deve trazer success falso e a mensagem de e-mail inválido.

Requisição POST no Swagger com e-mail inválido retornando falha de validação
O Flunt barra o e-mail inválido antes de qualquer acesso ao banco.

Depois, teste o POST com um e-mail correto. Agora a resposta traz o e-mail e o número do cartão gerado.

Requisição POST no Swagger com e-mail válido retornando o número do cartão virtual
Com um e-mail válido, o handler gera e salva o cartão.

Por fim, teste o GET informando o mesmo e-mail. A API retorna os cartões criados, ordenados pela data de criação.

Requisição GET no Swagger listando os cartões virtuais de um e-mail
O GET lista os cartões gravados no banco em memória.

Conclusão

Chegamos ao fim. Criamos uma API com EF Core simples, mas que passa por vários conceitos importantes, como separação em camadas, commands, handlers, validação com notificações, repositório, injeção de dependência e testes.

Embora o .NET Core 3.1 já esteja fora de suporte, a estrutura dessa API com EF Core continua útil em versões mais novas. Então, ao migrar, basta atualizar o framework e os pacotes e trocar o banco em memória por um banco real.

Se encontrar algum bug ou quiser sugerir melhorias, o código da API original está neste repositório no GitHub. Pull requests são bem-vindos.

FAQ

Posso usar o banco em memória do EF Core em produção?

Não. A própria Microsoft avisa que o provedor em memória não foi projetado para produção nem para desempenho.

Por que usar commands em vez da entidade na requisição?

Porque os commands isolam o contrato da API das regras do domínio. Assim, você muda a entrada e a saída sem expor ou alterar a entidade.

Qual a diferença entre AddScoped e AddTransient?

O AddScoped cria uma instância por requisição, enquanto o AddTransient cria uma nova instância a cada injeção.

Para que serve o repositório falso nos testes?

Ele substitui o acesso ao banco por uma implementação vazia. Com isso, os testes de unidade verificam apenas a regra do handler.

O .NET Core 3.1 ainda tem suporte?

Não. O suporte oficial terminou em 13 de dezembro de 2022, então projetos novos devem usar uma versão suportada do .NET.

Glossário

Termo Significado
EF Core Entity Framework Core, ORM da Microsoft para acesso a dados no .NET
Entidade Classe do domínio que representa um conceito de negócio persistido
Command Objeto que transporta os dados de entrada ou de saída de uma operação
Handler Classe que executa a regra de negócio de um command
Repositório Abstração que encapsula o acesso aos dados
DbContext Classe do EF Core que gerencia a conexão e o mapeamento com o banco
DbSet Coleção do EF Core que representa uma tabela
Injeção de dependência Técnica em que uma classe recebe de fora os objetos de que precisa
Flunt Biblioteca de validações baseada em notificações
Swagger Ferramenta que documenta e permite testar APIs pelo navegador

Fontes