Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/exception-names-become-error-codes && read --section="top" 0%
Architecture

Exception Names Become Error Codes

One middleware turns ConferenceNotFoundException into the wire code conference_not_found via Humanizer - a naming convention promoted to API contract, with a 400-versus-404 tension and Error-level logs it never resolved.

By Kumar Chandrachooda 13 Nov 2025 4 min read
An exclamation shape stamped neatly into a labelled envelope

Error handling is where API designs go to sprawl. Every controller grows its own try/catch dialect, every developer their own status-code opinions, and within a year the error surface has more formats than the success surface. The DevMentors ModularMonolith estate compresses all of it into one middleware, one base exception, and one naming convention — a genuinely reusable design that also ships two instructive tensions it never resolves. Part 10 ended with modules throwing exceptions; this part reads what catches them.

The taxonomy is a class hierarchy

The whole error model starts in Shared.Abstractions with a base class whose most important line is its constructor's visibility:

public class CustomException : Exception
{
    protected CustomException(string message) : base(message)
    {
    }
}

protected means you cannot throw a bare CustomException; you can only inherit it. The class is a marker with enforcement — deriving from it is how a module declares “this failure is a domain error, expected, and safe to describe to clients”. Modules then mint their vocabulary, one class per condition:

internal class ConferenceNotFoundException : CustomException
{
    public Guid ConferenceId { get; }

    public ConferenceNotFoundException(Guid conferenceId)
        : base($"Conference with ID: '{conferenceId}' was not found.")
    {
        ConferenceId = conferenceId;
    }
}

Speakers adds SpeakerNotFoundException and SpeakerAlreadyExistsException; Conferences adds HostNotFoundException. No error-code enum, no constants file, no registry — the type system is the taxonomy.

The envelope

ErrorHandlerMiddleware in Shared.Infrastructure sits at the top of the pipeline (UseInfrastructure registers it before routing) and does the translation:

catch (Exception exception)
{
    var statusCode = 500;
    var code = "error";
    var message = "There was an error.";

    _logger.LogError(exception, exception.Message);
    if (exception is CustomException customException)
    {
        statusCode = 400;
        var exceptionType = customException.GetType();
        if (!_codes.TryGetValue(exceptionType, out var errorCode))
        {
            code = customException.GetType().Name.Underscore().Replace("_exception", string.Empty);
            _codes.TryAdd(exceptionType, code);
        }
        else
        {
            code = errorCode;
        }
        message = customException.Message;
    }

    context.Response.StatusCode = statusCode;
    await context.Response.WriteAsJsonAsync(new {code, message});
}

The centrepiece is the code derivation: Humanizer's Underscore() turns ConferenceNotFoundException into conference_not_found_exception, and the Replace trims the suffix, yielding conference_not_found on the wire. The class name is the error code — one convention, zero bookkeeping, and every new exception arrives pre-coded. Results are memoized in a ConcurrentDictionary<Type, string> so the string surgery runs once per type; the double-checked TryGetValue/TryAdd shape is exactly right for a singleton middleware under concurrency. Anything that is not a CustomException collapses to a deliberately opaque 500 — {"code":"error","message":"There was an error."} — leaking nothing, which is the correct default for unexpected failures.

This is the second place the estate turns a CLR type name into a wire-level protocol string — the event registry of part 6 does it for topics. Same elegance, same fragility: rename ConferenceNotFoundException and every client matching on conference_not_found breaks, with no compiler anywhere in the blast radius. A convention this load-bearing deserves one pinning test enumerating the expected codes; the estate, as ever, has none.

Tension one: the missing conference wears two status codes

Follow one condition through both of its paths. GET /conferences-module/conferences/{id} with an unknown id: the controller checks for null and returns NotFound() — a 404. PUT or DELETE on the same unknown id: the service throws ConferenceNotFoundException, the middleware catches it — a 400, because the middleware maps every domain error to 400. One condition, two status codes, chosen by which layer happened to notice.

The root cause is an information gap, not carelessness: by the time the middleware holds the exception, the HTTP-relevant distinction — not-found versus invalid-request versus conflict — has been erased into a common base class. SpeakerAlreadyExistsException is a textbook 409; it ships as 400 for the same reason. Fixes are cheap and all known in the net5.0 era: a StatusCode property on CustomException, a marker interface per family (INotFoundError), or a type-to-status map in the middleware. Today you would also emit RFC 7807 ProblemDetailstype, title, status, traceId — rather than a bespoke two-field envelope; the anonymous {code, message} predates that convention's dominance and lacks the one field support teams need most, a correlation id to find the log line. The envelope's code would slot perfectly into ProblemDetails' type; the design is one refactor from current best practice, which for 2021 teaching code is a compliment.

Tension two: expected errors, alarming logs

The subtler issue is a single line's position: _logger.LogError(exception, ...) runs before the type check, so every failure — including a user asking for a conference that does not exist — logs a full exception with stack trace at Error severity. Domain errors are, by the design's own definition, expected outcomes; logging them as errors means an afternoon of users mistyping ids is indistinguishable, on a dashboard, from an afternoon of outages. Alert fatigue is built in at line one. The fix is to branch first: Warning (or Information) for CustomException, Error for the rest. It is a two-line change, and it is the difference between logs that page you and logs you stop reading.

The design, weighed

The scorecard is lopsided in the pattern's favour. One catch site for the whole estate; a taxonomy that grows by declaring a class; codes that cannot drift from their exceptions because they are derived from them; modules that never touch HttpContext. The tensions — status-code flattening, alarm-level logging, no trace id — are all one-file fixes, and all invisible until you read the middleware end to end, which is rather the point of this series. The distilled rule: deriving your wire contract from your type names buys consistency at the price of making refactoring a breaking change — take the deal, but write the contract down somewhere a rename will trip over.

The exceptions this envelope dresses up are thrown by services guarding their entities — and how those entities guard themselves (mostly: they do not, and comments stand watch instead) is the domain-model reading of invariants enforced by comment, next.