16 KiB
.NET API Project Design Document
A reference architecture for building ASP.NET Core Web APIs with Dapper, JWT authentication, and a co-located data access layer.
Project Structure
All code lives in a single project. The data access layer is organized as a namespace within the same assembly rather than a separate project.
MyApi/
├── MyApi.sln
├── MyApi.csproj
├── Program.cs
├── appsettings.json
├── appsettings.Development.json
├── Dockerfile
├── Configuration/
│ └── (Options classes for external services)
├── DataAccess/
│ ├── DatabaseManager.cs
│ ├── Models/
│ │ ├── DatabaseObjects/
│ │ └── DataTransferObjects/
│ └── Repositories/
└── src/
├── BaseClasses/
│ └── BaseController.cs
├── Controllers/
├── Middleware/
├── Models/
│ └── ResponseTypes/
└── Services/
Layer Responsibilities
Controllers (src/Controllers/)
Controllers handle HTTP concerns only: request validation, claims extraction, and response shaping. They delegate all business logic to services and all data access to repositories.
[ApiController]
[Route("api/[controller]")]
public class ExampleController : BaseController
{
private readonly ExampleRepository _exampleRepository = new();
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<ResponseResult<ExampleResponse>>> GetById(Guid id)
{
var (userId, error) = GetUserIdFromClaims();
if (error != null) return error;
var entity = await _exampleRepository.GetByIdAsync(id);
if (entity == null) return NotFound();
return Ok(new ResponseResult<ExampleResponse>
{
Success = true,
Data = MapToResponse(entity)
});
}
}
Conventions:
- Use
[ApiController]and[Route("api/[controller]")]on every controller - Inherit from
BaseControllerfor claims extraction helpers - Return
ActionResult<ResponseResult<T>>for consistent response envelopes - Apply
[Authorize]or[Authorize(Roles = "...")]per-endpoint - Use
[ProducesResponseType]attributes for OpenAPI documentation
Base Controller (src/BaseClasses/BaseController.cs)
Provides protected helper methods that extract and validate JWT claims, reducing boilerplate across controllers.
public class BaseController : ControllerBase
{
protected (Guid userId, ActionResult? error) GetUserIdFromClaims() { ... }
protected (string email, List<string> roles, ActionResult? error) GetUserDetailsFromClaims() { ... }
protected bool HasRole(string role) { ... }
protected bool HasAnyRole(params string[] roles) { ... }
}
Add additional claim extraction helpers as needed for your domain (e.g., tenant ID, organization ID).
These methods return tuples with an optional error ActionResult, enabling early-return patterns in controller actions.
Services (src/Services/)
Services encapsulate business logic that spans multiple repositories or involves non-trivial orchestration. Register them via DI as scoped services.
When to use a service vs. calling a repository directly from a controller:
- Direct repository call: Simple CRUD with no cross-cutting logic
- Service: Multi-step operations, external API calls, or any logic spanning multiple repositories
Module registration pattern for grouping related services:
public static class MyModuleExtensions
{
public static IServiceCollection AddMyModule(this IServiceCollection services)
{
services.AddScoped<IMyService, MyService>();
services.AddScoped<MyRepository>();
return services;
}
}
Repositories (DataAccess/Repositories/)
Each repository maps to a single domain entity and serves as a thin C# wrapper around stored procedures. Repositories create their own database connections per call and are responsible for calling stored procedures and assembling the results into typed objects.
All query logic lives in stored procedures in the database — repositories contain no inline SQL.
public class ExampleRepository
{
private IDbConnection Connection =>
new MySqlConnection(DatabaseManager.ConnectionString);
public async Task<dbExample?> GetByIdAsync(Guid id)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbExample>(
"sp_Example_GetById",
new { Id = id },
commandType: CommandType.StoredProcedure);
}
public async Task<IEnumerable<dbExample>> GetByOwnerAsync(Guid ownerId)
{
using var db = Connection;
return await db.QueryAsync<dbExample>(
"sp_Example_GetByOwner",
new { OwnerId = ownerId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> InsertAsync(dbExample entity)
{
using var db = Connection;
var rows = await db.ExecuteAsync(
"sp_Example_Insert",
new { entity.Id, entity.Name, entity.CreatedAt },
commandType: CommandType.StoredProcedure);
return rows > 0;
}
/// For stored procedures that return multiple result sets,
/// use QueryMultipleAsync to assemble a composite object.
public async Task<ExampleWithDetails?> GetWithDetailsAsync(Guid id)
{
using var db = Connection;
using var multi = await db.QueryMultipleAsync(
"sp_Example_GetWithDetails",
new { Id = id },
commandType: CommandType.StoredProcedure);
var example = await multi.ReadSingleOrDefaultAsync<dbExample>();
if (example == null) return null;
var tags = (await multi.ReadAsync<dbTag>()).ToList();
var history = (await multi.ReadAsync<dbHistoryEntry>()).ToList();
return new ExampleWithDetails
{
Example = example,
Tags = tags,
History = history
};
}
}
Conventions:
- One repository per domain entity
- All methods are
async Task<T> - Use
using var db = Connection;to ensure connection disposal - All data access goes through stored procedures — no inline SQL in repositories
- Always pass
commandType: CommandType.StoredProcedureto Dapper calls - Use
QueryMultipleAsyncwhen a stored procedure returns multiple result sets, and assemble the results into composite objects - Return
db-prefixed model types from database operations - Stored procedure naming convention:
sp_{Entity}_{Action}(e.g.,sp_Example_GetById,sp_User_Insert)
Database Objects (DataAccess/Models/DatabaseObjects/)
Plain C# classes that map directly to database tables. Use the db prefix to distinguish them from response DTOs.
public class dbExample
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public bool Deleted { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
Conventions:
dbprefix on all database object class names- Use
requiredfor non-nullable columns - Use nullable types (
string?,Guid?) for optional columns - Include
CreatedAtandUpdatedAttimestamps on all entities - Use
bool Deletedfor soft-delete support where needed
Data Transfer Objects (DataAccess/Models/DataTransferObjects/)
DTOs define the shape of data entering repositories from controllers. They are distinct from both database objects and response types.
public class CreateExampleDTO
{
public required string Name { get; set; }
public string? Description { get; set; }
}
public class UpdateExampleDTO
{
public Guid Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
}
Response Types (src/Models/ResponseTypes/)
Response types define the shape of data returned to API consumers. Use a standard envelope.
public class ResponseResult<T>
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
}
Domain-specific response classes map from database objects, excluding internal fields and reshaping data for client consumption.
Configuration
Options Pattern
External service configuration uses the ASP.NET Core options pattern. Each integration gets its own options class.
// Configuration/MyServiceOptions.cs
public class MyServiceOptions
{
public const string SectionName = "MyService";
public required string ApiKey { get; set; }
public required string BaseUrl { get; set; }
}
// appsettings.json
{
"MyService": {
"ApiKey": "...",
"BaseUrl": "..."
}
}
// Program.cs
services.Configure<MyServiceOptions>(
builder.Configuration.GetSection(MyServiceOptions.SectionName));
DatabaseManager
A static class that provides the connection string to all repositories. Configures Dapper's snake_case-to-PascalCase column mapping.
public static class DatabaseManager
{
private static IConfiguration? _configuration;
private static IConfiguration Configuration
{
get
{
if (_configuration == null)
{
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
_configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddEnvironmentVariables()
.Build();
}
return _configuration;
}
}
public static string ConnectionString =>
Configuration.GetConnectionString("DefaultConnection")
?? throw new MissingFieldException("DefaultConnection not configured");
}
appsettings.json Structure
{
"Jwt": {
"Key": "<base64-encoded-signing-key>",
"Issuer": "MyApi"
},
"ConnectionStrings": {
"DefaultConnection": "Server=...;Database=...;Uid=...;Pwd=...;Pooling=true;Max Pool Size=200;Min Pool Size=5;"
},
"MyService": { ... },
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
Authentication & Authorization
JWT Configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(
Convert.FromBase64String(builder.Configuration["Jwt:Key"]!)),
ClockSkew = TimeSpan.Zero
};
});
Token Service
A scoped service that generates JWTs with custom claims. Add whatever domain-specific claims your application requires (e.g., user ID, tenant/org ID, email).
public class TokenService
{
public string GenerateToken(Guid userId, string email, List<string> roles)
{
var claims = new List<Claim>
{
new("user_id", userId.ToString()),
new(ClaimTypes.Email, email)
};
// Add domain-specific claims as needed
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var key = new SymmetricSecurityKey(Convert.FromBase64String(_config["Jwt:Key"]!));
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
expires: DateTime.UtcNow.AddMinutes(15),
claims: claims,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Role-Based Authorization
Define roles as string constants in a static class and apply them via attributes.
public static class UserRoles
{
public const string Admin = "Admin";
public const string User = "User";
// Add application-specific roles as needed
}
// Usage on endpoints
[Authorize(Roles = UserRoles.Admin)]
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id) { ... }
Middleware Pipeline
Configure middleware in Program.cs in the following order:
var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>(); // 1. Catch unhandled exceptions
app.UseMiddleware<RequestLoggingMiddleware>(); // 2. Log all requests/responses
app.UseCors(); // 3. CORS policy
app.UseHttpsRedirection(); // 4. HTTPS enforcement
app.UseAuthentication(); // 5. JWT validation
app.UseAuthorization(); // 6. Role/policy checks
app.MapControllers(); // 7. Route to controllers
Request Logging Middleware
Captures request/response details to the database for observability:
- HTTP method, path, query string, status code
- Request and response bodies (excluding multipart/form-data)
- User ID extracted from JWT claims
- Processing time in milliseconds
- Request trace ID for correlation
Program.cs Template
var builder = WebApplication.CreateBuilder(args);
// --- Kestrel configuration ---
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
});
// --- Configuration binding ---
builder.Services.Configure<MyServiceOptions>(
builder.Configuration.GetSection(MyServiceOptions.SectionName));
// --- Authentication ---
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* see JWT Configuration above */ });
builder.Services.AddAuthorization();
// --- Service registration ---
builder.Services.AddScoped<TokenService>();
builder.Services.AddControllers();
// Module registrations
builder.Services.AddMyModule();
// Background services (if needed)
// builder.Services.AddHostedService<MyBackgroundWorker>();
// --- CORS ---
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
});
});
// --- Forwarded headers (for reverse proxy) ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Dependency Registration Summary
| Registration | Lifetime | Purpose |
|---|---|---|
TokenService |
Scoped | JWT generation |
IMyService / MyService |
Scoped | Business logic |
| Options classes | Singleton (via Configure<T>) |
External service config |
| External API wrappers | Singleton | Stateless third-party service clients |
| Background workers | Hosted Service | Async background processing |
| Repositories | Instantiated directly | Data access (no DI needed) |
Repositories are instantiated directly in controllers (new ExampleRepository()) rather than registered in DI. This is appropriate because they are stateless and create their own connections per call.
Key Technology Choices
| Concern | Technology |
|---|---|
| Framework | ASP.NET Core 9.0 |
| ORM | Dapper (micro-ORM, stored procedure calls only) |
| Database | MySQL via MySql.Data |
| Authentication | JWT Bearer tokens |
Patterns & Conventions Summary
- Repository pattern with one repository per domain entity
- Stored procedures exclusively — all query logic lives in the database, repositories are thin wrappers
- Snake_case DB columns mapped automatically to PascalCase C# properties
- Standard response envelope (
ResponseResult<T>) on all endpoints - Claims-based authorization with role constants and BaseController helpers
- Options pattern for all external configuration
- Module extension methods for grouping related service registrations
- Async/await throughout — no synchronous database calls
- Soft deletes via
Deletedboolean flag where needed - Audit timestamps (
CreatedAt,UpdatedAt) on all entities