Sending a duplicate category name to my API was supposed to be a normal, expected failure. The name already exists, the client should get a clear rejection, and nothing unusual should happen. Instead, the code path for that request ended at a status I did not expect: 500 Internal Server Error.
A 500 usually means the server did something wrong. But the client had simply asked to create a name that already existed. That mismatch is what made me stop and look at how the application decided which failures become which HTTP responses.
How the Category Code Throws
When a category is created or renamed, the service checks whether the name is already taken and throws when it is:
var nameExists = await _dbContext.Categories
.AnyAsync(category => category.Name == request.Name, cancellationToken);
if (nameExists)
{
throw new InvalidOperationException("Category name is already registered");
}
At the time, the exception was an InvalidOperationException with a message. The service is doing the right thing in principle: it detected a real application condition and stopped. The problem was on the other side of the boundary, in how that condition was translated into an HTTP response.
The Handler That Read Messages
The application has one place that turns exceptions into HTTP responses. In the version I had written, the mapping looked at the exception message for one specific case:
var (statusCode, message) = exception switch
{
ArgumentException => (StatusCodes.Status400BadRequest, exception.Message),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, exception.Message),
InvalidOperationException when exception.Message == "Email is already registered"
=> (StatusCodes.Status400BadRequest, exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
Read the third branch closely. It matches InvalidOperationException, but only when the message is exactly "Email is already registered". That branch existed because the user flow also threw InvalidOperationException for a duplicate email, and I had special-cased it.
The category message is "Category name is already registered". It is a different sentence. So the branch did not match, the exception fell through to _, and _ is the 500. The failure was not really about categories at all. It was about a handler that only recognized one wording.
The Contract Was in the Text
The part that took me a moment to see is that this is not a bug in one line. It is a hidden contract between two files.
CategoryService throws an exception whose meaning lives in its message. ApiExceptionHandler decides the response by reading that message. Neither file refers to the other. Nothing in the type system connects them. The only thing that links them is a sentence, and a sentence is not something the compiler checks.
That has a consequence that is easy to underestimate. I could improve the message in the service to something friendlier, or fix a typo, or translate it, and the HTTP behavior would change even though the underlying failure is identical. A wording change would quietly turn a 400 into a 500. The failure would look like a new server problem, while the actual condition, a duplicate name, had not changed at all.
I also want to be accurate about what happened, because it would be easy to make this sound worse than it was. There was no production incident, and no user reported it. The code was deterministic: if a duplicate category name was sent, it would return 500. I found it while reviewing the exception handler against what the services actually throw, not from a failing test and not from a log. The test that now checks the 400 was added together with the fix, so it protects the new behavior rather than having exposed the old one.
Giving the Failure a Type
The change was to stop describing the failure in text and start describing it with a type. I added a small exception class:
public class DuplicateResourceException : Exception
{
public DuplicateResourceException(string message) : base(message)
{
}
}
The service now throws that type, keeping the same message for the client:
if (nameExists)
{
throw new DuplicateResourceException("Category name is already registered");
}
And the handler matches on the type instead of the message:
var (statusCode, message) = exception switch
{
ArgumentException => (StatusCodes.Status400BadRequest, exception.Message),
DuplicateResourceException => (StatusCodes.Status400BadRequest, exception.Message),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
};
Now the meaning of the failure travels in the type. The message is still there, but only as something to show the client. Changing the wording no longer changes the status code, because the handler never reads the wording to decide.
Where the Responsibility Sits
There is a useful separation in this design. The service decides that a duplicate exists, which is an application-level fact. The handler decides how that fact appears over HTTP. The service does not need to know that duplicates map to a 400, and the handler does not need to know which table or which rule produced the duplicate.
That is the part I like about the change. Before, the handler was guessing application meaning from arbitrary text. After, the service states the meaning once, in the type, and the handler translates it. The translation stays in one place, and it stops depending on how someone phrased a message somewhere else.
I do not think this is the only correct way to model expected failures. Some teams return result objects instead of throwing, and that is a reasonable choice. In this project the condition was already propagated as an exception, so the smaller and more honest fix was to make that exception say what it means.
Two Layers of Uniqueness
Category names are also protected in the database. The categories table has a unique index on Name, and AppDbContext configures it:
modelBuilder.Entity<Category>()
.HasIndex(category => category.Name)
.IsUnique();
So there are two checks, and they are not the same thing. The application check exists to give a clear, friendly rejection before anything is written. The unique index is the actual invariant: even if two requests passed the application check at the same time, the database would refuse the second insert.
I want to be careful not to overstate this. The project does not implement special handling for a concurrent insert that reaches the unique index. That path would surface as a database exception, and the handler has no branch for it, so it would become a 500. The application check is a friendly pre-check; the unique index is what guarantees uniqueness. They work together, and the second one is the one that actually holds under concurrency.
The Tests That Protect It
There are two tests for this, and they check different things.
The handler test verifies the mapping directly. It runs the handler with a DuplicateResourceException and asserts the status:
[InlineData(typeof(DuplicateResourceException), "Category name is already registered", 400, "Category name is already registered")]
This proves the translation step: a duplicate resource maps to 400 with the message preserved, and it does so for the category wording as well as the email wording.
The integration test verifies the behavior over real HTTP:
[Fact]
public async Task CreateCategory_DuplicateName_ReturnsBadRequestInsteadOfServerError()
{
using var adminClient = await CreateAuthenticatedClientAsync("admin@mail.com");
var response = await adminClient.PostAsJsonAsync(
"/api/categories", new CreateCategoryRequest("Backend"));
var body = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.False(body!.Success);
Assert.Equal("Category name is already registered", body.Message);
}
It posts a category name that already exists from the seed data and asserts that the API answers 400 with the expected message. This is the test that would have failed before the change, because the endpoint would have answered 500. There is a matching test for the update path as well.
The two are not redundant. The unit test checks the mapping rule in isolation. The integration test checks that a real request through the whole pipeline ends at the right status, which is the thing a client actually sees.
The Trade-off
Adding a custom exception type is not free. If I created a distinct class for every small condition, the code would fill with exception types that carry one message each, and that would be worse than the problem it solves.
What made this one worth it is that it is not category-specific. DuplicateResourceException is thrown by the category service, the user service, and the auth service, for duplicate category names and duplicate emails. One type covers a real category of failure that appears in several places, so the handler has one branch to maintain instead of a growing list of message comparisons. The abstraction matches a genuine pattern in the code rather than a single call site.
What I Learned
I had been treating an exception message as if it were part of the program’s logic. It is not. A message is written for a human to read, and it can change for reasons that have nothing to do with behavior. When another part of the code reads that message to make a decision, it creates a dependency that the compiler cannot see and cannot protect.
An expected failure is still a failure, and it deserves to be described in a way the code can rely on. For this project, that meant giving the duplicate condition its own type and letting the handler translate the type, instead of parsing text. The message stayed the same for the client. The only thing that changed is that the response no longer depends on the exact words.