ApiResponse & Controllers
Every endpoint returns the same envelope shape, so the frontend never has to guess whether a payload is data or an error message.
The envelope
public class ApiResponse<T>
{
public T? Value { get; set; }
public bool Succeeded { get; set; }
public string? Message { get; set; }
public static ApiResponse<T> Ok(T? value = default, string? message = null)
=> new() { Succeeded = true, Message = message, Value = value };
public static ApiResponse<T> Fail(string? message = null)
=> new() { Succeeded = false, Message = message };
}
Controller convention
Controllers stay thin: call the service, wrap the result, translate expected exceptions into the right status code. [Authorize(Roles = "Admin")] gates admin-only actions per-endpoint rather than per-controller when a controller mixes access levels.
[ApiController]
[Route("[controller]")]
[Authorize]
public class ResourceController : ControllerBase
{
private readonly IResourceService _service;
public ResourceController(IResourceService service) => _service = service;
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetAsync(Guid id)
{
try
{
var result = await _service.GetDetailAsync(id);
return Ok(ApiResponse<ResourceDto>.Ok(result));
}
catch (KeyNotFoundException ex)
{
return NotFound(ApiResponse<ResourceDto>.Fail(ex.Message));
}
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateAsync([FromBody] CreateResourceRequest request)
{
try
{
await _service.CreateAsync(request);
return Ok(ApiResponse<object>.Ok());
}
catch (ArgumentException ex)
{
return BadRequest(ApiResponse<object>.Fail(ex.Message));
}
}
}
Catch-all error handler
An [ApiExplorerSettings(IgnoreApi = true)] controller hooked up via app.UseExceptionHandler("/Error") catches anything that slips past controller-level try/catch, so a raw 500 never leaks a stack trace to the client.
[ApiExplorerSettings(IgnoreApi = true)]
public class ErrorController : ControllerBase
{
[Route("/error")]
public IActionResult HandleError()
=> StatusCode(500, ApiResponse<string>.Fail("An unexpected error occurred."));
}
Custom JSON converter: force UTC
Registered once via AddJsonOptions, this guarantees every DateTime in and out of the API is treated as UTC — a common source of silent bugs when the client and server are in different time zones.
public class UtcDateTimeJsonConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
=> DateTime.SpecifyKind(reader.GetDateTime(), DateTimeKind.Utc);
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
=> writer.WriteStringValue(
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
}
// Program.cs
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new UtcDateTimeJsonConverter());
});