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

ApiResponse.cs
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.

ResourceController.cs
[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.

ErrorController.cs
[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.

UtcDateTimeJsonConverter.cs
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());
});