ATLAS + GOTCHA -- Part 5
Hands-On: Build a Users API in 45 Minutes with ATLAS + GOTCHA
The Problem
Four articles. Two frameworks. A lot of theory.
If you’ve been following the series, you know ATLAS (a 5-phase checklist that makes you think before you prompt) and GOTCHA (a 6-layer prompt format that translates that thinking into structured AI instructions). If not, the short version: ATLAS forces you to define the problem properly. GOTCHA makes sure the AI gets a complete, organized specification instead of a vague request.
But knowing something and doing something are different. This article is about doing.
We’re going to build a real users API. PostgreSQL database, JWT authentication, repository pattern, deployed to Kubernetes. The kind of thing that takes a full day to build by hand, full of decisions and backtracking and “wait, how does EF Core migrations work again.”
We’ll do it in 45 minutes. Because we won’t be guessing. We’ll run ATLAS first, translate to GOTCHA, and hand the AI a prompt that leaves nothing open to interpretation.
Then we’ll look at the code, understand what was generated, and know exactly why each piece exists.
The Solution
The ATLAS + GOTCHA workflow applied to a real project. The point isn’t to be clever — it’s to be systematic.
Here’s the ATLAS checklist, filled in for this project:
[A] ARCHITECT
Purpose: REST API for user management. CRUD operations + JWT auth.
Users: Frontend React app and other internal services (machine-to-machine).
Constraints:
- JWT tokens expire in 1 hour (refresh tokens not in scope for this article)
- Passwords hashed with Argon2id (OWASP recommended: m=64MB, t=3, p=4)
- Email must be unique per user
- API must return RFC 7807 problem details on errors
Tech decisions: .NET 10, PostgreSQL 16, Entity Framework Core, Docker, AKS
Out of scope: email verification, OAuth2, password reset flow
[T] TRACE
Registration:
1. POST /users → validate body → check email unique → hash password → insert → return 201
Login:
2. POST /auth/login → validate body → find user by email → verify password → issue JWT → return 200
Get user:
3. GET /users/{id} → validate JWT → find user → return 200 (no password hash)
Update user:
4. PUT /users/{id} → validate JWT → check ownership → validate body → update → return 200
Delete user:
5. DELETE /users/{id} → validate JWT → check ownership → soft delete → return 204
[L] LINK
| From | To | Method | Contract | Failure mode |
| --------------- | ------------ | --------- | --------------------- | ------------------------- |
| React frontend | Users API | HTTPS/JWT | OpenAPI 3.0 | 401/403 on auth failure |
| Users API | PostgreSQL | TCP/EF | EF Core entities | Retry 3x, then 503 |
| Other services | Users API | HTTPS/JWT | Bearer token (m2m) | 401 on expired token |
| K8s liveness | /healthz | HTTP | 200 OK | Pod restart after 3 fails |
[A] ASSEMBLE
Phase 1: Data layer
- PostgreSQL schema + EF Core migration
- User entity + AppDbContext
- IUserRepository interface + UserRepository
Phase 2: Business logic
- IUserService + UserService (create, get, update, delete, authenticate)
- PasswordService (hash + verify with Argon2id)
- JwtService (issue token with claims)
Phase 3: API layer
- UsersController (CRUD)
- AuthController (login)
- JWT middleware configuration
- FluentValidation request validators
Phase 4: Cross-cutting
- Global error handler → RFC 7807 problem details
- Health check endpoint (/healthz)
- Structured logging (Serilog)
Phase 5: Deployment
- Dockerfile (multi-stage, non-root user)
- K8s Deployment + Service + Ingress
- K8s Secret for DB connection string + JWT secret
[S] STRESS-TEST
Scenario 1: Concurrency
- 200 concurrent registrations with same email → only 1 succeeds, rest get 409
- Verify unique constraint at database level, not just application
Scenario 2: Auth
- Expired JWT → 401 with clear message
- Tampered JWT → 401
- Valid JWT for user A accessing user B → 403
Scenario 3: Input validation
- Empty email → 422 with field-level error details
- Password shorter than 8 chars → 422
- SQL injection attempt in email field → 422 (FluentValidation + parameterized queries)
Now the GOTCHA prompt, mapped directly from ATLAS:
=== GOALS (from Architect) ===
Build a .NET 10 Web API for user management:
- POST /users — register (returns 201 with user id)
- POST /auth/login — returns JWT (1h expiry)
- GET /users/{id} — requires valid JWT (returns user without password hash)
- PUT /users/{id} — requires JWT, owner only (returns updated user)
- DELETE /users/{id} — requires JWT, owner only, soft delete (returns 204)
All errors return RFC 7807 problem details.
=== ORCHESTRATION (from Assemble) ===
Build in this order:
1. User entity + AppDbContext + EF Core migration
2. IUserRepository + UserRepository (PostgreSQL via EF Core)
3. PasswordService (Argon2id hash + verify)
4. JwtService (issue HS256 token with sub, email, exp claims)
5. IUserService + UserService (orchestrates repository + password + JWT)
6. FluentValidation validators for each request DTO
7. UsersController + AuthController
8. JWT middleware + global error handler (RFC 7807)
9. Health check (/healthz), Serilog structured logging
10. Dockerfile (multi-stage) + K8s manifests
=== TOOLS (from Link) ===
- .NET 10 Web API
- Entity Framework Core 10 + Npgsql.EntityFrameworkCore.PostgreSQL
- Konscious.Security.Cryptography.Argon2 (password hashing)
- System.IdentityModel.Tokens.Jwt (JWT)
- FluentValidation.AspNetCore
- Serilog + Serilog.AspNetCore
- xUnit + Moq + Testcontainers.PostgreSql (integration tests)
- Docker + Kubernetes
=== CONTEXT (from Trace + Link) ===
- Consumed by a React frontend and internal services (machine-to-machine)
- Deploys to AKS (Azure Kubernetes Service)
- No external identity provider — this service owns user credentials
- Soft delete: IsDeleted boolean flag, filtered via EF Core global query filter
- Password hash stored in DB, never returned in API responses
=== HEURISTICS (from Assemble) ===
DO:
- Repository pattern: IUserRepository injected into UserService
- Async/await for all DB and I/O operations
- Return DTOs, never EF entities directly from controllers
- Validate all inputs with FluentValidation before hitting service layer
- Use parameterized queries (EF Core handles this)
- Log structured events (user created, login attempt, auth failure)
DON'T:
- Don't put business logic in controllers
- Don't catch and swallow exceptions — let global handler format them
- Don't return stack traces in error responses
- Don't store plain-text passwords, even temporarily
- Don't use AutoMapper — manual mapping is fine and explicit
=== ARGS (from Stress-test + Architect) ===
DB_CONNECTION: from env var (PostgreSQL connection string)
JWT_SECRET: from env var (min 32 chars)
JWT_EXPIRY_MINUTES: 60
ARGON2_MEMORY: 65536, ARGON2_ITERATIONS: 3, ARGON2_PARALLELISM: 4
PORT: 8080
K8s namespace: users-api
K8s replicas: 2
Health check: GET /healthz → 200 OK
Execute
Let’s walk through the key parts of the code the AI generates from this prompt. Not every file — just the parts that matter.
The Entity and DbContext
// Domain/User.cs
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; }
}
// Infrastructure/UsersDbContext.cs
public class UsersDbContext(DbContextOptions<UsersDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(e =>
{
e.HasKey(u => u.Id);
e.HasIndex(u => u.Email).IsUnique()
.HasFilter("\"IsDeleted\" = false");
e.Property(u => u.Email).HasMaxLength(320).IsRequired();
e.Property(u => u.FirstName).HasMaxLength(100).IsRequired();
e.Property(u => u.LastName).HasMaxLength(100).IsRequired();
// Global query filter: soft delete
e.HasQueryFilter(u => !u.IsDeleted);
});
}
}
The global query filter on IsDeleted is important. Every query through EF Core automatically excludes deleted users. You don’t have to remember to add WHERE "IsDeleted" = false everywhere. The unique index on Email also filters out deleted users — so a user can re-register with the same email after deletion.
The Repository
// Application/IUserRepository.cs
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<User?> GetByEmailAsync(string email, CancellationToken ct = default);
Task<bool> ExistsAsync(string email, CancellationToken ct = default);
Task CreateAsync(User user, CancellationToken ct = default);
Task SoftDeleteAsync(Guid id, CancellationToken ct = default);
}
// Infrastructure/UserRepository.cs
public class UserRepository(UsersDbContext db) : IUserRepository
{
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) =>
db.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
public Task<bool> ExistsAsync(string email, CancellationToken ct = default) =>
db.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
public async Task CreateAsync(User user, CancellationToken ct = default)
{
db.Users.Add(user);
await db.SaveChangesAsync(ct);
}
public async Task SoftDeleteAsync(Guid id, CancellationToken ct = default)
{
await db.Users.Where(u => u.Id == id)
.ExecuteUpdateAsync(u => u.SetProperty(x => x.IsDeleted, true), ct);
}
}
The JWT Service
// Services/JwtService.cs
public class JwtService(IConfiguration config)
{
private readonly string _secret = config["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET not configured");
private readonly int _expiryMinutes = int.Parse(config["JWT_EXPIRY_MINUTES"] ?? "60");
public string IssueToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_expiryMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
The Controller
// Controllers/UsersController.cs
[ApiController]
[Route("users")]
public class UsersController(IUserService userService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Register(
[FromBody] RegisterRequest req, CancellationToken ct)
{
var result = await userService.RegisterAsync(req.Email, req.Name, req.Password, ct);
return result.IsSuccess
? CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value)
: Problem(result.Error, statusCode: result.StatusCode);
}
[HttpGet("{id:guid}")]
[Authorize]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var result = await userService.GetByIdAsync(id, ct);
return result.IsSuccess ? Ok(result.Value) : Problem(result.Error, statusCode: 404);
}
[HttpPut("{id:guid}")]
[Authorize]
public async Task<IActionResult> Update(
Guid id, [FromBody] UpdateUserRequest req, CancellationToken ct)
{
var callerId = Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
if (callerId != id)
return Problem("You can only update your own account.", statusCode: 403);
var result = await userService.UpdateAsync(id, req.Name, ct);
return result.IsSuccess ? Ok(result.Value) : Problem(result.Error, statusCode: 404);
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var callerId = Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
if (callerId != id)
return Problem("You can only delete your own account.", statusCode: 403);
await userService.DeleteAsync(id, ct);
return NoContent();
}
}
The Problem() method here uses ASP.NET Core’s built-in problem details support, which produces RFC 7807-compliant responses automatically.
The Kubernetes Manifests
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: users-api
namespace: users-api
spec:
replicas: 2
selector:
matchLabels:
app: users-api
template:
metadata:
labels:
app: users-api
spec:
containers:
- name: users-api
image: myacr.azurecr.io/users-api:latest
ports:
- containerPort: 8080
env:
- name: DB_CONNECTION
valueFrom:
secretKeyRef:
name: users-api-secrets
key: db-connection
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: users-api-secrets
key: jwt-secret
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
Two things worth noting here. First, both the DB connection string and JWT secret come from a Kubernetes Secret — never from environment variables baked into the image. Second, runAsNonRoot: true and allowPrivilegeEscalation: false are basic security hardening that many teams forget until they need it for compliance.
What the AI Got Right (and What We Adjusted)
This is the honest part. The AI generated 90% of this correctly from the GOTCHA prompt. Three things we adjusted:
-
The global query filter for soft delete. The AI added
WHERE deleted_at IS NULLto each repository method individually. We replaced that with the EF Core global query filter — less code, less chance of forgetting it in a new method. -
The
Problem()return in controllers. The AI used a custom error response format. We switched to ASP.NET Core’s built-inProblem()method to get RFC 7807 compliance for free. -
The K8s
securityContext. The AI generated the deployment without security context. We addedrunAsNonRootandallowPrivilegeEscalation: falsebased on the heuristics we defined — but we had to add them manually because the GOTCHA prompt didn’t specify them explicitly.
That last point is a lesson: Heuristics in GOTCHA need to be specific enough that “security hardening” becomes “add runAsNonRoot: true and allowPrivilegeEscalation: false to all K8s container specs.” The more specific the heuristic, the more the AI can follow it without guessing.
Template
Here’s the full GOTCHA prompt for the users API, ready to copy:
=== GOALS ===
Build a .NET 10 Web API for user management:
POST /users (register), POST /auth/login (JWT),
GET /users/{id} (auth required), PUT /users/{id} (auth + owner),
DELETE /users/{id} (auth + owner, soft delete).
All errors → RFC 7807 problem details.
=== ORCHESTRATION ===
1. User entity + AppDbContext + EF Core migration
2. IUserRepository + UserRepository
3. PasswordService (Argon2id, m=64MB, t=3, p=4)
4. JwtService (HS256, claims: sub, email, jti)
5. IUserService + UserService
6. FluentValidation validators for all request DTOs
7. UsersController + AuthController
8. JWT middleware + global error handler
9. Health check (/healthz) + Serilog
10. Dockerfile (multi-stage, non-root) + K8s manifests
=== TOOLS ===
.NET 10, EF Core 10, Npgsql, Konscious.Security.Cryptography.Argon2,
System.IdentityModel.Tokens.Jwt, FluentValidation.AspNetCore,
Serilog.AspNetCore, xUnit + Moq + Testcontainers.PostgreSql
=== CONTEXT ===
Deploys to AKS. Consumed by React frontend + internal services.
Soft delete via IsDeleted boolean (global EF query filter).
No external identity provider — this service owns credentials.
=== HEURISTICS ===
DO: repository pattern, async/await, return DTOs not entities,
log auth events, EF Core parameterized queries, manual DTO mapping.
DON'T: business logic in controllers, swallow exceptions,
return stack traces, store plain-text passwords.
K8s: runAsNonRoot: true, allowPrivilegeEscalation: false.
=== ARGS ===
DB_CONNECTION: env var, JWT_SECRET: env var (32+ chars),
JWT_EXPIRY_MINUTES: 60, ARGON2_MEMORY: 65536, ARGON2_ITERATIONS: 3, ARGON2_PARALLELISM: 4, PORT: 8080,
K8s namespace: users-api, replicas: 2,
CPU: 100m/500m, memory: 128Mi/256Mi
Challenge
You have a working API. Now it needs a CI/CD pipeline.
Before Article 6, think about this: what does a pipeline for this API need to do? Not just “build and deploy” — think about the steps, in order. What runs first? What has to pass before the next step starts? What security checks do you want before code reaches production?
In Article 6, we’ll build that pipeline on Azure DevOps — with automated security scanning, container image hardening, and a deployment to AKS. Using ATLAS + GOTCHA to design the pipeline before we write a single line of YAML.
If this series helps you, consider buying me a coffee.
Loading comments...