17 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. Services are either static classes (for stateless logic) or singletons instantiated directly where needed.
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
Static service pattern (preferred for stateless logic):
public static class MyService
{
public static async Task<Result> DoSomethingAsync(Guid entityId)
{
var repo = new ExampleRepository();
// orchestration logic
}
}
Singleton instance pattern (when the service needs initialization or holds config):
public class MyService
{
public static MyService Instance { get; private set; } = null!;
public static void Initialize(string apiKey, string baseUrl)
{
Instance = new MyService { _apiKey = apiKey, _baseUrl = baseUrl };
}
private string _apiKey;
private string _baseUrl;
public async Task<Result> DoSomethingAsync() { ... }
}
// In Program.cs
MyService.Initialize(
builder.Configuration["MyService:ApiKey"]!,
builder.Configuration["MyService:BaseUrl"]!);
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
Configuration Helper
A static helper class provides access to IConfiguration from anywhere in the application, without dependency injection.
// Configuration/ConfigHelper.cs
public static class ConfigHelper
{
public static IConfiguration Configuration { get; set; } = null!;
}
// Program.cs (before any services are used)
ConfigHelper.Configuration = builder.Configuration;
Configuration values are read directly where needed:
var apiKey = ConfigHelper.Configuration["MyService:ApiKey"];
var baseUrl = ConfigHelper.Configuration["MyService:BaseUrl"];
For services that need config at initialization, pass values explicitly during setup in Program.cs rather than reading config inside the service.
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 static helper class that generates JWTs with custom claims. Reads JWT config via ConfigHelper. Add whatever domain-specific claims your application requires (e.g., user ID, tenant/org ID, email).
public static class TokenService
{
public static 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(ConfigHelper.Configuration["Jwt:Key"]!));
var token = new JwtSecurityToken(
issuer: ConfigHelper.Configuration["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);
// --- Static configuration (must come first) ---
ConfigHelper.Configuration = builder.Configuration;
// --- Kestrel configuration ---
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
});
// --- Authentication ---
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* see JWT Configuration above */ });
builder.Services.AddAuthorization();
// --- Controllers ---
builder.Services.AddControllers();
// --- 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();
});
// --- Initialize singleton services ---
MyService.Initialize(
builder.Configuration["MyService:ApiKey"]!,
builder.Configuration["MyService:BaseUrl"]!);
var app = builder.Build();
app.UseForwardedHeaders();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Instantiation Patterns
| Component | Pattern | Example |
|---|---|---|
| Repositories | new in controllers |
private readonly ExampleRepository _repo = new(); |
| Stateless services | Static class | TokenService.GenerateToken(...) |
| Stateful services | Singleton with Initialize |
MyService.Instance.DoSomething() |
| Configuration | Static helper | ConfigHelper.Configuration["Key"] |
| Background workers | Hosted Service (only DI used) | builder.Services.AddHostedService<MyWorker>() |
The only use of the DI container is for framework-level concerns that require it: authentication, authorization, CORS, hosted background services, and forwarded headers. All application-level code uses static classes or direct instantiation.
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
- Static configuration helper for accessing
appsettings.jsonanywhere - No dependency injection for application code — static classes and direct instantiation only
- Async/await throughout — no synchronous database calls
- Soft deletes via
Deletedboolean flag where needed - Audit timestamps (
CreatedAt,UpdatedAt) on all entities