C
C#3w ago
Pouria

✅ How to standardize API response formats in C# ?

Hi everyone, I’m working on a C# project and I’ve run into a huge inconsistency problem with my API responses. Here’s what’s happening: I’m using FluentValidation for request validation. If validation fails, I throw a validation exception. I have a global exception handler that catches exceptions and returns ProblemDetails. For successful requests, I return my normal response DTO. The problem is that my API responses are all over the place — the client sometimes gets ProblemDetails, sometimes a DTO, with no consistent structure. This is making the API confusing and hard to consume. I’m lost on how to clean this mess.
89 Replies
Pobiega
Pobiega3w ago
As long as you lock down your error responses, i'd say thats pretty normal its not at all unusual to have two incompatible return types, one for 2xx and one for 4xx/5xx ie, success and failure results You could ofc use a unified type, some kind of Result<T> that wraps a T or an error, with props for both and a "ok" flag that tells you if it was good or bad.. or something but imho, that goes against http philosophy, we already have the http status code so we know if it went good or not
Pouria
PouriaOP3w ago
Thanks for the input, Pobiega. Right now I'm separating success (DTO) and errors (ProblemDetails). The part I'm stuck on is whether I should define custom exceptions (UserNameExistsException, ProductNotFoundException, etc.) and let my middleware translate them into ProblemDetails or if I'm just over-engineering this.
Pobiega
Pobiega3w ago
Ah, so "soft" errors as opposed to hard errors ("failed to connect to database") imho, product not found isnt an exception, its just a fact of life. What I do in most of my code that needs to handle that is I use a functional result type to indicate that, and in my controller/handler I turn that into a 404 or whatever other appropriate error code so in terms of an API response, that'd just be a status 404 with no body, assuming the route clearly indicated that itself example: /api/products/2673123
Pouria
PouriaOP3w ago
Okey, but what about username already exists exception? That's a domain rule right ?
Pobiega
Pobiega3w ago
Like for a registration attempt? Sure. You could model that as a bad request, but I think its more appropriate to use a 200 with a failure result variant. This is where your original question gets interesting I guess I still dunno about introducing a "general purpose" structure for this that you'd use everywhere, but you certainly can create a success/failure result type and use it for your endpoints that need to communicate that type of error ie, "you did nothing wrong, but it failed for reasons you need to be aware of"
Pouria
PouriaOP3w ago
Sure thanks a lot, I think I'm just overthinking it instead of actually writing code. So i just use the http response code for what's happening in the api and keeping it simple. Thanks for the advice
Pobiega
Pobiega3w ago
public abstract record ResultWrapper<T>(bool Ok);

public record Success<T>(T Data) : ResultWrapper<T>(true);
public record Failure<T>(string Error) : ResultWrapper<T>(false);

[HttpPost]
[Route("register")]
[AllowAnonymous]
public async Task<ResultWrapper<LoginResponse>> Register([FromBody] LoginRequest request)
{
var success = _loginProvider.Register(request.Username, request.Password);
if (!success)
{
return new Failure<LoginResponse>("Username already taken");
}

return new Success<LoginResponse>(new LoginResponse(request.Username));
}
public abstract record ResultWrapper<T>(bool Ok);

public record Success<T>(T Data) : ResultWrapper<T>(true);
public record Failure<T>(string Error) : ResultWrapper<T>(false);

[HttpPost]
[Route("register")]
[AllowAnonymous]
public async Task<ResultWrapper<LoginResponse>> Register([FromBody] LoginRequest request)
{
var success = _loginProvider.Register(request.Username, request.Password);
if (!success)
{
return new Failure<LoginResponse>("Username already taken");
}

return new Success<LoginResponse>(new LoginResponse(request.Username));
}
super simple example this can be cleaned up a lot with some implicit methods but if you run this method twice, you'd get
{
"data": { "username": "Pobiega" }
"ok": true
}
// and ...
{
"error": "Username already taken",
"ok": false
}
{
"data": { "username": "Pobiega" }
"ok": true
}
// and ...
{
"error": "Username already taken",
"ok": false
}
imho, thats a decent start for handling "soft" errors.. and if you do it this way for things like username already taken, you could reasonably follow the same pattern for validation errorsm but I think they should still be 400s in general, be careful about showing exception details to the user
Kuurama
Kuurama3w ago
If it's scoped to endpoint definition, why not use the TypedResult static class? with Results<> and like Results<Ok<ProductResponse>, NotFound>
Kuurama
Kuurama3w ago
at the bottom
private static async Task<Results<Ok<RankedMap>, NotFound>> GetRankedMapAsync(
RankedMapId rankedMapId, ServerDbContext dbContext) => await dbContext.RankedMaps
.Where(x => x.Id == rankedMapId)
.Select(RankedMapMappers.MapRankedMapExpression)
.FirstOrDefaultAsync() switch
{
null => TypedResults.NotFound(),
var rankedMap => TypedResults.Ok(rankedMap)
};
private static async Task<Results<Ok<RankedMap>, NotFound>> GetRankedMapAsync(
RankedMapId rankedMapId, ServerDbContext dbContext) => await dbContext.RankedMaps
.Where(x => x.Id == rankedMapId)
.Select(RankedMapMappers.MapRankedMapExpression)
.FirstOrDefaultAsync() switch
{
null => TypedResults.NotFound(),
var rankedMap => TypedResults.Ok(rankedMap)
};
It's just an example but the OpenApi stuff should just infer it :catok:
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I don't get it Why would I add an extension I mean, I think I do
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
But your exemple isn't right
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I think I got confused by the example lol
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
But Then I loose expression body syntax I hate it lol
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
And I dislike having TypeResult.Extension.OrOrNotFound as the first thing I write
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I would rather keep the Switch expression ngl. It doesn't feel like hard to write not read
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Saying Switch expression isn't readablenis a skill isaue
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Yeah but I don't like it
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Switch expression is a blessing
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Why making my own extensions
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Readability is good with switch expression
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
It just does what it says it does You don't have to have a team having ten different ways to do stuffs with different method or names If you can just embrace the new syntax. :_SuwonShrug:
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I know switch expression wouldn't require much audit, nor a new extension method
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I still find it perfectly readable. It's a matter of being used to writing it ngl
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Not gonna lie. My fun time is about reducing the amount of variables X)
MODiX
MODiX3w ago
TeBeCo
private static async Task<OkOrNotFound<RankedMap>> GetRankedMapAsync(RankedMapId rankedMapId, ServerDbContext dbContext)
=> TypeResults.Extensions.OkOrNotFound(await dbContext.RankedMaps
.Where(x => x.Id == rankedMapId)
.Select(RankedMapMappers.MapRankedMapExpression)
.FirstOrDefaultAsync());
private static async Task<OkOrNotFound<RankedMap>> GetRankedMapAsync(RankedMapId rankedMapId, ServerDbContext dbContext)
=> TypeResults.Extensions.OkOrNotFound(await dbContext.RankedMaps
.Where(x => x.Id == rankedMapId)
.Select(RankedMapMappers.MapRankedMapExpression)
.FirstOrDefaultAsync());
React with ❌ to remove this embed.
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I dislike having a pipeline read Blackward that's a no go for me
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I would rather add an extension on IQUERYABLE
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
FirstOrNotFound
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
But then again, it doesn't feel authentic
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Yes. But why caring at this point. You're not gonna change to somethint else I don't care writting services for data retrieval for crud. It's already tied to efcore
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
It's new abstractions you have to adapt and mimic everywhere No new abstraction, just pure C# without new stuffs
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I don't do it that much so it's alright
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Adding a method that abstracts a ternary is an abstraction
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I'm not affraid of writting an extra line. Who's gonna spend all it's time on crud like that? better use AI at this point lmao
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
it reads from top to bottom. Instead of having to read a method name, you read null turning into something, and what you want into something else. It's the same thing. But it readasdifferently
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
You would still test it the same ngl
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Would rather make an http IQueryable extension :_SuwonShrug:
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Which reada from top to bottom
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Why being afraid of coupling like this
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
What are the risks though Tell me
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
On endpoints with logic of course not :_SuwonShrug:
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
GitHub
GuildSaber/src/GuildSaber.Api/Features/Auth/AuthEndpoints.cs at mai...
Contribute to GuildSaber/GuildSaber development by creating an account on GitHub.
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
:kappa:
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
You don't have to be so haughty though
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
If that's the proper word at least I'm non native so finding the right Words to express my feelings isn't optimal haha
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
I get what you say though An extension could indeed cut things down I would still rather extend IQueryable though I don't see why that would be an issue. Even less code than before. Straight up full easy crud
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
If i need to add cache, I will just find the proper syntax for it And it might just be with => too :kappa: :kekw: I just found that switch expression have some nice use cases Might not be the most optimal way of writting indeed But I like seing it. Feels fun and looks good
Unknown User
Unknown User3w ago
Message Not Public
Sign In & Join Server To View
Kuurama
Kuurama3w ago
Team decision. Agreeing on an extension is also troublesome sometime Being switch expression or an extension will do almost the same thing And compiler exhaustiveness just make it good too. There is 1 reason I use the switch expression thouch It's for consistancy I use DU's for service returns. So I can explicitely map the return types using the same syntax.
Kuurama
Kuurama3w ago
I'm not yet quite sure about what good or bad things would be. Still needs a lot of tinkering, but I believe that's how C#'s gonna be written in 2 years. I'm more like a R&D guy, I love to just find ways around design problems and designing api contracts. I also kinda found myself deeply interrested into the Functional paradigm world soo yeah. We might never agree on some things lol @TeBeCo

Did you find this page helpful?