API RESTful construída com .NET 10 seguindo os princípios de Clean Architecture, CQRS com MediatR e autenticação via JWT.
- Fluxo de Trabalho Git
- Arquitetura
- Estrutura de Pastas
- Pré-requisitos
- Configuração
- Comandos
- Camadas
- Endpoints
- Convenções
- Testes
Toda feature, correção ou melhoria é desenvolvida em uma branch dedicada, criada a partir de main:
| Tipo | Prefixo da branch | Exemplo |
|---|---|---|
| Nova funcionalidade | feature/ |
feature/create-product |
| Correção de bug | fix/ |
fix/token-expiry-validation |
| Hotfix urgente em produção | hotfix/ |
hotfix/null-reference-login |
| Refatoração | refactor/ |
refactor/exception-handlers |
| Documentação | docs/ |
docs/update-readme |
| Chores (configs, deps) | chore/ |
chore/update-packages |
# Criar e mudar para uma nova branch
git checkout -b feature/nome-da-feature
# Ao finalizar, abrir Pull Request para mainNunca commite diretamente em main. Toda mudança entra via Pull Request com ao menos uma revisão.
Todos os commits seguem o padrão Conventional Commits:
<type>(<scope opcional>): <descrição curta em inglês>
[corpo opcional]
[rodapé opcional, ex: Breaking change, closes #issue]
| Tipo | Quando usar |
|---|---|
feat |
Nova funcionalidade |
fix |
Correção de bug |
refactor |
Mudança de código que não é feat nem fix |
docs |
Alterações somente em documentação |
test |
Adição ou correção de testes |
chore |
Tarefas de manutenção (deps, config, CI) |
perf |
Melhoria de performance |
ci |
Mudanças em pipelines de CI/CD |
Exemplos:
feat(products): add create product command and handler
fix(auth): handle expired token on refresh
refactor(exceptions): replace middleware with IExceptionHandler chain
docs: add git workflow section to README
test(domain): add unit tests for Result pattern
chore: add .gitignore for .NET solutionO projeto segue Clean Architecture com regras estritas de dependência entre camadas:
Voltiq.Exceptions → (sem dependências Voltiq)
Voltiq.API → Voltiq.Application + Voltiq.Infrastructure + Voltiq.Exceptions
Voltiq.Application → Voltiq.Domain (Voltiq.Exceptions transitivo)
Voltiq.Infrastructure → Voltiq.Application + Voltiq.Domain
Voltiq.Domain → Voltiq.Exceptions
voltiq/
├── src/
│ ├── Voltiq.API/ # Entrada da aplicação (controllers, handlers de exceção, DI)
│ │ ├── Controllers/
│ │ │ └── BaseApiController.cs # Controller base com MediatR + ToErrorResult → ProblemDetails
│ │ ├── ExceptionHandlers/ # IExceptionHandler para erros inesperados
│ │ │ ├── UnauthorizedExceptionHandler.cs
│ │ │ └── GlobalExceptionHandler.cs
│ │ └── Program.cs
│ │
│ ├── Voltiq.Application/ # Casos de uso (CQRS, validators, interfaces)
│ │ ├── Common/
│ │ │ ├── Behaviors/
│ │ │ │ └── ValidationBehavior.cs # Pipeline MediatR para validação
│ │ │ └── Interfaces/
│ │ │ ├── IApplicationDbContext.cs
│ │ │ ├── ICurrentUserService.cs
│ │ │ └── ITokenService.cs
│ │ ├── Features/ # Commands e Queries por feature
│ │ └── DependencyInjection.cs
│ │
│ ├── Voltiq.Domain/ # Núcleo do domínio
│ │ ├── Common/
│ │ │ └── Result.cs # Result pattern (railway-oriented)
│ │ ├── Entities/
│ │ │ ├── BaseEntity.cs # Id (GUID) + DomainEvents
│ │ │ └── AuditableEntity.cs # + CreatedAt, CreatedBy, UpdatedAt
│ │ ├── Events/
│ │ │ ├── IDomainEvent.cs
│ │ │ └── BaseDomainEvent.cs
│ │ ├── Interfaces/
│ │ │ ├── IUnitOfWork.cs
│ │ │ └── Repositories/
│ │ │ ├── IRepository.cs
│ │ │ └── User/
│ │ │ └── IUserRepository.cs
│ │ └── ValueObjects/
│ │ └── ValueObject.cs
│ │
│ ├── Voltiq.Exceptions/ # Tipos de erro + exceções + mensagens centralizadas
│ │ ├── Errors/
│ │ │ ├── Error.cs # Classe base de erro tipado
│ │ │ ├── ValidationError.cs # Erro de validação (com PropertyName)
│ │ │ ├── NotFoundError.cs # Recurso não encontrado
│ │ │ └── ConflictError.cs # Conflito de unicidade
│ │ ├── Exceptions/
│ │ │ ├── DomainException.cs # Exceção de invariante de domínio
│ │ │ └── NotFoundException.cs # Recurso não encontrado (exception)
│ │ └── Resources/
│ │ ├── ResourceErrorMessages.resx # Todas as mensagens de erro (pt-BR)
│ │ └── ResourceErrorMessages.Designer.cs
│ │
│ └── Voltiq.Infrastructure/ # EF Core, JWT, repositórios
│ ├── Auth/
│ │ ├── TokenService.cs
│ │ └── CurrentUserService.cs
│ ├── Persistence/
│ │ ├── ApplicationDbContext.cs
│ │ └── Repositories/
│ │ ├── Repository.cs # Repositório genérico
│ │ ├── UnitOfWork.cs
│ │ └── User/
│ │ └── UserRepository.cs # Repositório especializado (ExistsUserAsync)
│ └── DependencyInjection.cs
│
└── tests/
├── Voltiq.Domain.Tests/
├── Voltiq.Application.Tests/
└── Voltiq.Infrastructure.Tests/
- .NET 10 SDK
- PostgreSQL (local ou via Docker)
Edite src/Voltiq.API/appsettings.json (ou use variáveis de ambiente / User Secrets em desenvolvimento):
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=voltiq;Username=postgres;Password=SUA_SENHA"
},
"JwtSettings": {
"SecretKey": "SUA_CHAVE_SECRETA_COM_32_CARACTERES_OU_MAIS",
"Issuer": "Voltiq.API",
"Audience": "Voltiq.Client",
"ExpiresInMinutes": "60"
}
}| Chave | Descrição |
|---|---|
ConnectionStrings:DefaultConnection |
Connection string do PostgreSQL (formato Npgsql) |
JwtSettings:SecretKey |
Chave de assinatura JWT — mínimo 32 caracteres |
JwtSettings:Issuer |
Emissor do token (padrão: Voltiq.API) |
JwtSettings:Audience |
Audiência do token (padrão: Voltiq.Client) |
JwtSettings:ExpiresInMinutes |
Tempo de expiração do token em minutos (padrão: 60) |
# Compilar a solução
dotnet build Voltiq.slnx
# Executar todos os testes
dotnet test Voltiq.slnx
# Executar testes de um projeto específico
dotnet test tests/Voltiq.Domain.Tests
dotnet test tests/Voltiq.Application.Tests
dotnet test tests/Voltiq.Infrastructure.Tests
# Executar um teste específico
dotnet test tests/Voltiq.Domain.Tests --filter "FullyQualifiedName~NomeDaClasse.NomeDoMetodo"
# Executar a API
dotnet run --project src/Voltiq.APINúcleo da aplicação, sem dependências externas.
BaseEntity— todas as entidades herdam desta classe. ForneceId(GUID gerado automaticamente) e uma coleção deDomainEvents.AuditableEntity— estendeBaseEntitycomCreatedAt,CreatedByeUpdatedAt.ValueObject— base para objetos de valor com igualdade por componentes.IDomainEvent/BaseDomainEvent— contrato e base para eventos de domínio (incluemOccurredOn).IRepository<T>— interface genérica de repositório (GetByIdAsync,AddAsync,Update,Remove).IUnitOfWork— interface para persistir mudanças (SaveChangesAsync).Result/Result<T>— padrão railway-oriented com erros tipados (Error,ValidationError,NotFoundError,ConflictError). Handlers retornamResult<T>para falhas esperadas em vez de lançar exceções.- Exceções —
DomainException(violação de invariante de domínio) eNotFoundException(recurso não encontrado) — reservadas para falhas fora do pipeline MediatR.
Contém todos os casos de uso como Commands e Queries via MediatR.
- Cada feature fica em
Features/<NomeDaFeature>/Commands/<NomeDoCommand>/ou.../Queries/. - Cada command/query tem seu próprio handler no mesmo diretório.
- Validators FluentValidation ficam no mesmo diretório do command/query e são executados automaticamente pelo
ValidationBehaviorantes do handler. - Interfaces de infraestrutura (
IApplicationDbContext,ICurrentUserService,ITokenService) são definidas aqui e implementadas na camada de Infrastructure.
Implementações de infraestrutura.
ApplicationDbContext— EF Core com PostgreSQL (Npgsql). Aplica configurações da assembly automaticamente.Argon2PasswordHasher— implementaIPasswordHasherusando Argon2id (viaKonscious.Security.Cryptography.Argon2). Parâmetros: 4 iterações, 64 MB de memória, paralelismo 2.Repository<T>— implementação genérica deIRepository<T>usando EF Core.UnitOfWork— delegaSaveChangesAsyncaoApplicationDbContext.TokenService— gera tokens JWT com claims deuserId,userNameeroles.CurrentUserService— lêuserId,userNameeisAuthenticateddoHttpContextviaIHttpContextAccessor.
Ponto de entrada da aplicação.
BaseApiController— controller base com[ApiController]e[Route("api/v{version:apiVersion}/[controller]")]. InjetaISenderviaHttpContext.RequestServices. ExpõeToErrorResult(Result)que converte erros tipados doResultemProblemDetails(ValidationProblemDetailspara 400,ProblemDetailspara 404/409/500).ExceptionHandlers/— tratamento de exceções não esperadas viaIExceptionHandler(UnauthorizedExceptionHandler→ 401,GlobalExceptionHandler→ 500).Program.cs— registra todos os serviços e configura o pipeline.
O sistema utiliza Serilog como provider de logging, configurado inteiramente via appsettings.json (sem código fixo em Program.cs).
| Sink | Destino | Formato |
|---|---|---|
| Console | Saída padrão | [HH:mm:ss LVL] SourceContext Mensagem |
| File | logs/voltiq-YYYYMMDD.log |
Texto com timestamp completo, rotação diária, retém 30 arquivos |
| Ambiente | Default | Microsoft / System | EF Core queries |
|---|---|---|---|
Produção (appsettings.json) |
Information |
Warning |
— |
Desenvolvimento (appsettings.Development.json) |
Debug |
Warning |
Information |
UseSerilogRequestLogging() loga automaticamente cada requisição com método, path, status code e tempo de resposta:
[10:45:32 INF] HTTP GET /api/v1/clients responded 200 in 42.3 ms
Para ajustar níveis ou adicionar novos sinks (ex.: Seq, Elastic), edite a seção Serilog em appsettings.json:
"Serilog": {
"MinimumLevel": { "Default": "Information" },
"WriteTo": [
{ "Name": "Console" },
{ "Name": "File", "Args": { "path": "logs/voltiq-.log", "rollingInterval": "Day" } }
]
}O diretório
logs/está no.gitignoree não é versionado.
Todos os endpoints seguem o padrão api/v{N}/.... A versão atual é v1.
O versionamento é implementado via Asp.Versioning.Mvc:
- URL path — a versão faz parte da URL (ex.:
/api/v1/clients). - Padrão assumido — requisições sem versão assumem v1 por padrão (
AssumeDefaultVersionWhenUnspecified = true). - Cabeçalho de resposta —
api-supported-versions: 1.0é retornado em todas as respostas.
Cada controller declara explicitamente sua versão com [ApiVersion("1.0")].
Autentica um usuário com e-mail e senha e retorna um JWT token.
Request body:
{
"email": "joao@example.com",
"password": "MinhaS3nh@Segura"
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
email |
string | ✅ | E-mail do usuário |
password |
string | ✅ | Senha do usuário |
Respostas:
| Status | Descrição |
|---|---|
200 OK |
{ "token": "<JWT>" } |
400 Bad Request |
Erro de validação (e-mail vazio/inválido ou senha vazia) |
401 Unauthorized |
E-mail ou senha inválidos |
Observações de segurança:
- Em caso de credenciais inválidas, a resposta 401 não indica se o e-mail existe ou não (mensagem genérica).
- O token JWT gerado deve ser enviado no cabeçalho
Authorization: Bearer <token>nas requisições autenticadas.
Retorna os dados do usuário atualmente autenticado, identificado pelo JWT enviado no cabeçalho Authorization.
Headers obrigatórios:
Authorization: Bearer <token>
Respostas:
| Status | Descrição |
|---|---|
200 OK |
{ "name": "João Silva", "email": "joao@example.com" } |
401 Unauthorized |
Token ausente ou inválido |
404 Not Found |
Usuário do token não existe mais no banco |
Cria um novo usuário na plataforma.
Request body:
{
"name": "João Silva",
"email": "joao@example.com",
"document": "529.982.247-25",
"password": "MinhaS3nh@Segura"
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name |
string | ✅ | Nome completo do usuário |
email |
string | ✅ | Endereço de e-mail válido e único |
document |
string | ✅ | CPF (11 dígitos) ou CNPJ (14 dígitos), com ou sem pontuação |
password |
string | ✅ | Mínimo 8 caracteres; armazenada como hash Argon2id |
Respostas:
| Status | Descrição |
|---|---|
201 Created |
{ "id": "<guid>", "token": "<JWT>" } — retorna token para auto-login |
400 Bad Request |
Erro de validação (campos inválidos) |
409 Conflict |
E-mail ou CPF/CNPJ já cadastrado |
Regras de validação:
- E-mail deve ser único no sistema.
- CPF e CNPJ são validados com verificação de dígitos verificadores. CPF com todos os dígitos iguais (e.g.,
111.111.111-11) são rejeitados. - Documento deve ser único no sistema.
- Senha é armazenada como hash Argon2id — nunca em texto puro.
Toda lógica de caso de uso vive em Features/ como commands ou queries:
Features/
└── Products/
├── Commands/
│ └── CreateProduct/
│ ├── CreateProductCommand.cs
│ ├── CreateProductCommandHandler.cs
│ └── CreateProductCommandValidator.cs
└── Queries/
└── GetProductById/
├── GetProductByIdQuery.cs
└── GetProductByIdQueryHandler.cs
Nos controllers, use Sender.Send(...) para despachar:
var result = await Sender.Send(new CreateProductCommand(...));// Entidade simples
public class Product : BaseEntity
{
public string Name { get; private set; }
}
// Entidade auditável
public class Order : AuditableEntity
{
public Guid CustomerId { get; private set; }
}- Sempre use GUID como chave primária.
- Levante eventos de domínio com
AddDomainEvent(new MeuEvento(...)).
Handlers retornam Result<T> (ou Result para operações sem retorno como Update/Delete) com erros tipados:
// Sucesso
return Result<Guid>.Success(user.Id);
// Sucesso sem valor (para Update/Delete → 204 No Content)
return Result.Success();
// Falha esperada — conflito (409)
return Result<Guid>.Failure(new ConflictError("Já existe um usuário com este e-mail."));
// Falha esperada — não encontrado (404)
return Result<GetUserResponse>.Failure(
new NotFoundError(string.Format(ResourceErrorMessages.ENTIDADE_NAO_ENCONTRADA, nameof(User), id)));Hierarquia de erros (em Voltiq.Exceptions/Errors/):
| Tipo | Uso | HTTP Status |
|---|---|---|
Error (base) |
Erro genérico | 500 |
ValidationError |
Falha de validação (com PropertyName) |
400 |
NotFoundError |
Recurso não encontrado | 404 |
ConflictError |
Conflito de unicidade | 409 |
Reserve exceções (DomainException) para violações de invariantes de domínio na camada Domain.
Crie um validator FluentValidation no mesmo diretório do command:
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThan(0);
// Validação de formato de Value Objects via TryParse
RuleFor(x => x.Email)
.NotEmpty().WithMessage(ResourceErrorMessages.USUARIO_EMAIL_OBRIGATORIO)
.Must(email => Email.TryParse(email, out _, out _))
.WithMessage(ResourceErrorMessages.USUARIO_EMAIL_INVALIDO);
}
}O ValidationBehavior no pipeline MediatR:
- Executa todos os validators automaticamente antes do handler.
- Se houver falhas, retorna
Result.Failure(...)comValidationErrortipados — sem lançar exceções. - O controller recebe o
ResultcomIsFailure == truee converte paraValidationProblemDetails(400).
Constraint: o
ValidationBehaviorexigewhere TResponse : Result, portanto todo command/query deve retornarResultouResult<T>.
O tratamento de erros usa uma abordagem dual:
1. Erros esperados → Result com erros tipados (dentro do pipeline MediatR)
O handler retorna Result.Failure(new ConflictError(...)) ou o ValidationBehavior retorna Result.Failure(new ValidationError(...)). O controller converte via ToErrorResult(result) em ProblemDetails nativo do ASP.NET Core:
| Tipo de Erro | HTTP Status | Resposta |
|---|---|---|
ValidationError |
400 Bad Request |
ValidationProblemDetails com errors dict |
NotFoundError |
404 Not Found |
ProblemDetails com detail |
ConflictError |
409 Conflict |
ProblemDetails com detail |
2. Erros inesperados → IExceptionHandler (fora do pipeline MediatR)
Para exceções não capturadas e exceções de autenticação, IExceptionHandler converte em application/problem+json:
| Handler | Captura | HTTP Status |
|---|---|---|
UnauthorizedExceptionHandler |
UnauthorizedAccessException |
401 Unauthorized |
GlobalExceptionHandler |
Exception (fallback) |
500 Internal Server Error |
Todas as mensagens de erro estão centralizadas em src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx, todas em pt-BR.
- Acesse via classe fortemente tipada
ResourceErrorMessagesdo namespaceVoltiq.Exceptions.Resources. - Nunca hardcode strings de erro em arquivos-fonte — adicione uma nova chave ao
.resxprimeiro. - Chaves com parâmetros usam
{0},{1}, etc.; usestring.Format(ResourceErrorMessages.Chave, ...)no ponto de uso. - Grupos de chaves: erros de domínio, erros de aplicação (por feature), títulos HTTP da API.
400 — Validation Error (ValidationProblemDetails)
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Falha de validação",
"status": 400,
"instance": "/api/v1/users",
"errors": {
"Email": ["O e-mail informado não é válido."],
"Password": ["A senha deve ter pelo menos 8 caracteres."]
}
}404 — Not Found (ProblemDetails)
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Não encontrado",
"status": 404,
"instance": "/api/v1/users/abc",
"detail": "A entidade 'User' com a chave 'abc' não foi encontrada."
}409 — Conflict (ProblemDetails)
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Conflito",
"status": 409,
"instance": "/api/v1/users",
"detail": "Já existe um usuário cadastrado com este e-mail."
}401 — Unauthorized
Retornado em dois cenários, ambos com corpo application/problem+json:
- Challenge JWT (token ausente, inválido ou expirado) — interceptado via
JwtBearerEvents.OnChallenge. UnauthorizedAccessExceptionlançada no código da aplicação — interceptada porUnauthorizedExceptionHandler.
{
"title": "Não autorizado",
"status": 401,
"instance": "/api/v1/clients"
}500 — Internal Server Error
{
"title": "An unexpected error occurred.",
"status": 500,
"instance": "/api/checkout"
}Campos restritos ao ambiente
Development:
traceId— incluído em todos os handlersstackTrace— incluído apenas noGlobalExceptionHandler
// Recurso não encontrado → 404
return Result<GetUserResponse>.Failure(
new NotFoundError(string.Format(ResourceErrorMessages.ENTIDADE_NAO_ENCONTRADA, nameof(User), id)));
// Conflito de unicidade → 409
return Result<Guid>.Failure(
new ConflictError(ResourceErrorMessages.USUARIO_EMAIL_JA_CADASTRADO));No controller:
var result = await Sender.Send(new GetUserQuery(id), cancellationToken);
if (result.IsFailure)
return ToErrorResult(result); // Converte para ProblemDetails automaticamente
return Ok(result.Value);Nunca retorne status HTTP diretamente da camada Application.
// Leitura
var product = await _repository.GetByIdAsync(id, cancellationToken);
// Escrita — sempre chame SaveChangesAsync via IUnitOfWork
await _repository.AddAsync(newProduct, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Queries especializadas — defina no repositório específico (ex: IUserRepository)
var exists = await _userRepository.ExistsUserAsync(document, email, cancellationToken);Não chame SaveChanges diretamente em IApplicationDbContext nos handlers.
Autenticação via Bearer Token. O token é gerado por ITokenService.GenerateToken(userId, userName, roles) e validado automaticamente pelo middleware de autenticação JWT do ASP.NET Core.
Acesse o usuário autenticado via ICurrentUserService:
var userId = _currentUserService.UserId;
var isAuthenticated = _currentUserService.IsAuthenticated;- Framework: xUnit
- Mocks: Moq
- Assertions: Shouldly
- Testes de integração (Infrastructure): Testcontainers com
Testcontainers.PostgreSql— sobe um container real de PostgreSQL para cada execução de teste.
Todo o projeto segue o padrão red → green → refactor:
- Red — escreva o teste antes da implementação
- Green — implemente o mínimo para o teste passar
- Refactor — melhore o código sem quebrar os testes
A primeira commit em uma branch de feature deve conter apenas os testes (test: ...). Os commits de implementação vêm em seguida.
Os projetos de teste espelham a camada correspondente em src/. Coloque novos testes no projeto correto:
| Camada testada | Projeto de testes |
|---|---|
Voltiq.Domain |
tests/Voltiq.Domain.Tests |
Voltiq.Application |
tests/Voltiq.Application.Tests |
Voltiq.Infrastructure |
tests/Voltiq.Infrastructure.Tests |