Consistent API Response Modeling: Why It Matters

Logan YoungLogan Young
3 min read

There are a few traits that make a developer stand out: clean code, consistent conventions, meaningful error handling… you know the list. When I first started building APIs, I was happy just returning Ok() or BadRequest() with either some data or an error message. It did the job. But as I got into more complex APIs - especially those integrating with middleware or external systems - that simplistic approach started to fall apart. The issue? Every API returned something different. One gave you a data object, another a plain string, another wrapped everything in some mystery envelope. Consuming those APIs became a chore. So I decided to fix that by designing a consistent response structure - one wrapper type to rule them all.


The Structure

Here's the foundation:

public abstract class ResultBase<TData, TError>(
    bool succeeded,
    IEnumerable<TError> errors = null,
    List<TData> items = null)
{
    public bool Succeeded { get; protected init; } = succeeded;
    public IEnumerable<TError> Errors { get; protected init; } = errors ?? Enumerable.Empty<TError>();
    public List<TData>? Items { get; protected init; } = items;

    public override string ToString() =>
        JsonConvert.SerializeObject(this, Formatting.Indented);
}

This gives you a reusable base that standardizes how results are structured. Then you build specific implementations per domain or feature:

public class MemberResult(
    bool succeeded,
    IEnumerable<MemberError>? errors = null,
    List<Member>? items = null)
    : ResultBase<Member, MemberError>(succeeded, errors, items)
{
    public static MemberResult Success(List<Member>? data = null) =>
        new(true, items: data);

    public static MemberResult Failed(IEnumerable<MemberError> errors) =>
        new(false, errors: errors);
}

Why This Helps

This approach gives you consistency and clarity. You get: A Succeeded flag: Quick way to tell if the operation worked. Typed error information: I split errors into Validation and Processing errors. Validation failures return a BadRequest, while processing issues (e.g., exceptions) return a Problem.

A uniform data container: Whether you return one item or many, it's always a list - super handy for predictable frontend handling.


Bonus Tip: Use Describer Classes for Errors

Don't just throw strings around. Use describers to standardize and structure your errors:

public static class EmailTemplateErrorDescriber
{
    public static EmailTemplateError DefaultError(Exception? ex)
    {
        var err = new EmailTemplateError
        {
            Code = nameof(DefaultError),
            Description = "Something went wrong.",
        };
        if (ex != null)
        {
            err.Comment = "See exception details.";
            err.Exception = ex;
        }
        return err;
    }
}

You do need an error structure as well, though. I've gone with a simple structure. Implementations just derive from this base can be extended to include additional error data (something like InnerExceptions) if you need it.

public class ErrorBase
{
   public string Code { get; set; }
   public string Description { get; set; }
   public string Comment { get; set; }
   public Exception Exception { get; set; }
   public ErrorTypes Type { get; set; }

    public override string ToString()
    {
      var json = JsonConvert.SerializeObject(this, Formatting.Indented);
      return json;
    }
}

public enum ErrorTypes
{
   Validation,
   Processing
}

Usage

Since the structure is generic and consistent, usage is simple and clear.

Successful responses

return EmailTemplateResult<EmailTemplateViewModel>.Success();
// or with data:
return EmailTemplateResult<EmailTemplateViewModel>.Success([item]);

Failed responses

return EmailTemplateResult<EmailTemplateViewModel>.Failed([
    EmailTemplateErrorDescriber.DefaultError(ex)
]);

Handling the Result in Your API

Your controller logic becomes clean and predictable:

var result = await emailTemplateDataService.GetAsync(pageNumber, pageSize, searchQuery, sort, sortDirection);if (!result.Succeeded)
{
    if (result.Errors.Any(x => x.Type == ErrorTypes.Validation))
        return BadRequest(result);
    return Problem(result.ToString());
}
return Ok(result);

Final Thoughts

This pattern isn't groundbreaking, but it makes life easier - for you, your frontend devs, and anyone else touching your APIs. Consistency pays off. Especially when things go wrong.

0
Subscribe to my newsletter

Read articles from Logan Young directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Logan Young
Logan Young