API em ASP.NET Core (.NET 10) estruturada como modular monolith, com módulos de negócio isolados em projetos próprios, autenticação via JWT Bearer Token e bloqueio de conta por tentativas de login falhas.
Raimundos Marques — Analista de Sistemas
Projeto construído com propósito duplo: consolidar estudo de ASP.NET Core e arquitetura modular monolith, com a intenção de evoluir para um serviço em produção.
- Arquitetura
- Stack técnica
- Módulos
- Segurança
- Tratamento de erros
- Endpoints
- Testes
- Como rodar localmente
- Docker
- CI/CD
- Estrutura de pastas
Modular monolith: um único processo/deploy, mas cada módulo de negócio é um projeto (.csproj) separado, não só uma pasta. Isso faz o isolamento ser garantido pelo compilador — um módulo literalmente não compila se referenciar outro — em vez de depender só de disciplina de code review.
Api (host) ──references──▶ Modules/Users
──references──▶ Modules/Authentication
──references──▶ Orion.SharedKernel
Modules/Users ──references──▶ Orion.SharedKernel
Modules/Authentication ──references──▶ Orion.SharedKernel
Modules/Users ✗ Modules/Authentication (nunca se referenciam entre si)
Dentro de cada módulo, a separação em camadas é feita por pasta, não por projeto:
Modules/Users/
├── Domain/ entidade rica (User), sem dependência de framework
│ └── Abstractions/ interfaces que o Domain define e a Infrastructure implementa
├── Application/ casos de uso (um por ação: CreateUserUseCase, RenameUserUseCase...)
│ └── Abstractions/ interfaces que a Application usa e a Infrastructure implementa
└── Infrastructure/ EF Core, hashing de senha, controllers HTTP
├── Persistence/
├── Security/
└── Web/
Um projeto por módulo + camadas por pasta foi uma decisão deliberada: separar camadas em projetos também (Users.Domain.csproj, Users.Application.csproj, ...) multiplicaria a quantidade de .csproj sem ganho proporcional de rigor — a mesma técnica de teste de arquitetura usada pra isolar módulos serviria igualmente pra isolar camada, sem precisar do overhead de projeto por camada.
O módulo Authentication precisa validar credenciais que pertencem ao módulo Users, mas não pode referenciar Users diretamente — isso quebraria o isolamento. A solução é inversão de dependência via um contrato no Orion.SharedKernel:
Orion.SharedKernel.Contracts.IUserCredentialsChecker— contrato, visível a todos os módulos.Users.Infrastructure.Security.UserCredentialsChecker— implementação real, dentro do módulo Users.Authenticationdepende só da interface; a implementação concreta é resolvida em runtime pelo container de DI, configurado no host (Api).
Projeto compartilhado com o mínimo indispensável usado por todos os módulos, sem dependência de ASP.NET Core:
Entity<TId>— base de entidade de domínio (igualdade por identidade).AppException/ErrorType— hierarquia de exceções de aplicação (AppNotFoundException,AppConflictException,AppUnauthorizedException,AppLockedException, ...).Contracts/IUserCredentialsChecker— contrato cross-module descrito acima.
src/Tests/Architecture/ModuleIsolationTests.cs lê o XML de cada .csproj de módulo e falha se encontrar um <ProjectReference> de um módulo apontando pra outro. Roda como parte da suíte de testes normal — qualquer tentativa de acoplar Users e Authentication diretamente quebra o build de testes, não só uma revisão de código.
| Categoria | Tecnologia |
|---|---|
| Runtime / framework | .NET 10 / ASP.NET Core Web API |
| Persistência | Entity Framework Core 10 + SQL Server |
| Autenticação | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
| Hash de senha | Microsoft.AspNetCore.Identity.PasswordHasher<TUser> |
| Documentação de API | OpenAPI nativo (Microsoft.AspNetCore.OpenApi) + Swagger UI (Swashbuckle.AspNetCore.SwaggerUI) |
| Testes | xUnit + Moq |
| Health check | Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore |
| Containerização | Docker multi-stage build |
Dono do ciclo de vida da conta: criação, renomear, trocar senha, verificar email, ativar/desativar. Schema próprio no banco (users).
Dono da autenticação: login, emissão de JWT e bloqueio de conta por tentativas falhas. Schema próprio no banco (auth), completamente independente do schema users — a única coisa que os dois módulos compartilham é o contrato IUserCredentialsChecker.
- Hash de senha:
PasswordHasher<User>do ASP.NET Core Identity (PBKDF2-HMACSHA256, 100k iterações, salt aleatório) — mantido oficialmente pela Microsoft, sem dependência de terceiro. - Autenticação: JWT Bearer assinado com HMAC-SHA256. Claims:
sub(id do usuário),email,jti.MapInboundClaims = falsegarante que essas claims cheguem intactas noHttpContext.User, sem o remapeamento legado do .NET. - Autorização: endpoints de Users exigem
[Authorize]e checam que o dono do token (sub) é o mesmo{id}da rota — outro usuário autenticado recebe 403, não 200.POST /api/userse todo/api/auth/*são públicos por natureza (registro e login/refresh não têm token ainda). - Refresh token: emitido junto do access token no login (
Authentication.Domain.RefreshToken), com rotação — cadaPOST /api/auth/refreshrevoga o token usado e emite um novo par. Reuso de um refresh token já rotacionado ou revogado é rejeitado (401). - Logout:
POST /api/auth/logoutrevoga o refresh token informado. Não invalida o access token já emitido (ele expira sozinho peloexp, curto por design). - Bloqueio de conta (
Authentication.Domain.LoginLockoutPolicy): 5 tentativas de login falhas em uma janela de 15 minutos bloqueiam novas tentativas — inclusive com a senha correta — até 15 minutos após a última falha. Cada tentativa (sucesso ou falha) é registrada emauth.LoginAttempts.
Toda exceção de domínio/aplicação herda de AppException e carrega um ErrorType. O GlobalExceptionHandler (Api/Exceptions) converte isso em ProblemDetails com o status HTTP correspondente:
ErrorType |
HTTP |
|---|---|
Validation |
400 |
Unauthorized |
401 |
Forbidden |
403 |
NotFound |
404 |
Conflict |
409 |
Locked |
423 |
Unexpected (qualquer outra exceção) |
500 |
Todos exigem Authorization: Bearer <token>, exceto a criação. Todos (exceto a criação) só aceitam operar sobre o {id} do próprio token — outro usuário autenticado recebe 403.
| Método | Rota | Ação | Auth |
|---|---|---|---|
POST |
/api/users |
Cria usuário | público |
GET |
/api/users/{id} |
Busca usuário por id | [Authorize] + dono |
PUT |
/api/users/{id}/name |
Renomeia | [Authorize] + dono |
PUT |
/api/users/{id}/password |
Troca senha | [Authorize] + dono |
POST |
/api/users/{id}/verify-email |
Marca email como verificado | [Authorize] + dono |
POST |
/api/users/{id}/activate |
Ativa | [Authorize] + dono |
POST |
/api/users/{id}/deactivate |
Desativa | [Authorize] + dono |
Todos públicos — são justamente os endpoints usados antes de (ou sem) ter um access token válido.
| Método | Rota | Ação |
|---|---|---|
POST |
/api/auth/login |
Login — retorna access token + refresh token |
POST |
/api/auth/refresh |
Troca um refresh token válido por um novo par (rotação) |
POST |
/api/auth/logout |
Revoga um refresh token |
| Método | Rota | Ação |
|---|---|---|
GET |
/health |
Verifica conectividade com os dois bancos (Users + Authentication) |
Documentação interativa em /swagger (ambiente Development).
src/Tests/Modules/Users— testes unitários do módulo Users (domínio + use cases com Moq).src/Tests/Architecture— teste de isolamento entre módulos.
dotnet test Orion.slnxPré-requisitos: .NET 10 SDK, SQL Server acessível, ferramenta dotnet-ef (dotnet tool install --global dotnet-ef).
# 1. Configurar segredos (nunca em appsettings.json)
dotnet user-secrets set "ConnectionStrings:DatabaseConnection" "<sua connection string>" --project src/Api/Api.csproj
dotnet user-secrets set "Jwt:SigningKey" "<chave aleatória de pelo menos 32 bytes>" --project src/Api/Api.csproj
# 2. Aplicar as migrations de cada módulo
dotnet ef database update --project src/Modules/Users/Users.csproj --startup-project src/Api/Api.csproj
dotnet ef database update --project src/Modules/Authentication/Authentication.csproj --startup-project src/Api/Api.csproj --context AuthenticationDbContext
# 3. Rodar
dotnet run --project src/Api/Api.csprojDois caminhos, propositalmente separados:
- Dev —
docker compose upaplicadocker-compose.yml+docker-compose.override.ymlautomaticamente. Monta o user-secrets e o certificado de desenvolvimento da máquina host; forçaASPNETCORE_ENVIRONMENT=Development. Nunca use isso em produção. - Produção — precisa ser explicitamente pedido, não é aplicado por acidente:
Segredos entram via variável de ambiente (
cp .env.production.example .env # preencher com valores reais docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dConnectionStrings__DatabaseConnection,Jwt__SigningKey), não via arquivo. A imagem expõeHEALTHCHECKbatendo em/health.
.github/workflows/deploy.yml roda a cada push em main (ou manualmente via workflow_dispatch), em três estágios sequenciais:
- test —
dotnet build+dotnet testna solução inteira, num runner hospedado pelo GitHub (ubuntu-latest). - migrate — aplica as migrations pendentes de
UserseAuthenticationcontra o banco de produção (dotnet ef database update, um por módulo). Só roda setestpassar. - deploy — chama a API do Dokploy (
POST /api/compose.deploy) pra disparar o redeploy do app. Só roda semigratepassar.
Um runner hospedado pelo GitHub roda na nuvem — ele não alcançaria um SQL Server on-premise sem exposição pra internet. Por isso o job migrate usa runs-on: self-hosted: você precisa registrar um runner self-hosted numa máquina da sua própria rede (que já enxergue o banco), em Settings → Actions → Runners → New self-hosted runner. É esse runner — não um do GitHub — que efetivamente executa o dotnet ef database update.
A aplicação está conectada ao Dokploy por integração de GitHub App, com o app configurado como tipo Compose — o próprio Dokploy clona o repo e builda a partir do docker-compose.yml, sem precisarmos publicar imagem em nenhum registry.
Importante: o toggle Auto Deploy dessa integração deve ficar desligado no painel do Dokploy. Se ele ficar ligado, o Dokploy tentaria deployar direto a cada push (via webhook do próprio GitHub App), em paralelo com esse workflow — e a ordem test → migrate → deploy deixaria de ser garantida (o container novo podendo subir antes da migration terminar). Com o toggle desligado, quem decide quando deployar é exclusivamente o job deploy, chamando a API depois que tudo antes dele passou.
Secrets necessários no repositório GitHub (Settings → Secrets and variables → Actions):
| Secret | Usado em | Onde conseguir |
|---|---|---|
DATABASE_CONNECTION_STRING |
migrate |
a mesma connection string do .env de produção |
JWT_SIGNING_KEY |
migrate (a checagem em Program.cs roda mesmo só pra gerar a migration) |
a mesma chave do .env de produção |
DOKPLOY_URL |
deploy |
URL base da sua instância Dokploy, sem barra no final |
DOKPLOY_API_TOKEN |
deploy |
perfil Dokploy → API/CLI → Generate API Key |
DOKPLOY_COMPOSE_ID |
deploy |
aparece na URL da aplicação no painel do Dokploy |
src/
├── Api/ host ASP.NET Core — Program.cs, Dockerfile, appsettings
├── Modules/
│ ├── Users/ Domain / Application / Infrastructure
│ └── Authentication/ Domain / Application / Infrastructure
├── Orion.SharedKernel/ Entity<TId>, AppException, contratos cross-module
└── Tests/
├── Architecture/ isolamento entre módulos
└── Modules/Users/ testes unitários do módulo Users