Skip to content

Implement best practice Not Found condition for API, MCP and Port/Adpater #70

Description

@goodtocode

📘 Best Practices for Handling 404s Across REST, MCP, and Port/Adapter Layers

🎯 Goal

Unify how “not found” conditions are handled across REST API endpoints, MCP tools, and internal port/adapters — while eliminating repetitive if (x == null) return NotFound() logic across hundreds of controllers. This ensures consistency, reduces brittleness, and keeps the domain/application layer pure.


🧩 1. Architectural Principle: “Not Found” Is Not Exceptional

Domain/Application Layer

  • “Not found” is an expected outcome.
  • Domain queries return:
    • T? for single-item queries
    • IEnumerable<T> for collections
  • Domain commands return:
    • A failure result only when business rules require the entity to exist
  • No exceptions for normal absence.

This prevents HTTP semantics from leaking into core logic.


🌐 2. REST API Behavior (Transport Semantics)

Single GET

  • If the domain returns null, the API returns 404 Not Found.

Collection GET

  • If the domain returns an empty list, the API returns 200 OK with an empty array.

REST rules apply only at the transport boundary.


🧱 3. MCP & Port/Adapter Behavior (Non-HTTP Semantics)

When bypassing the API, HTTP codes do not apply.

MCP Tools

  • Return null for missing single items.
  • Return empty arrays for collections.
  • Return structured errors only for business-rule violations.
  • Never throw exceptions for normal absence.

Port/Adapter Interfaces

  • GetByIdPort returns T?.
  • ListPort returns IEnumerable<T>.
  • Optional: use Result<T> for explicit outcomes.
  • No HTTP codes, no exceptions for normal absence.

This keeps internal modules predictable and decoupled from transport concerns.


🧼 4. Eliminating Repetitive 404 Logic in Controllers

Developers currently must remember to write:

if (result == null) return NotFound();

This is brittle and error‑prone across hundreds of endpoints.

✔️ Solution: Introduce a Base API Controller

Centralize the “single vs list” semantics in a shared base class.

Example:

public abstract class ApiControllerBase : ControllerBase
{
protected IActionResult SingleOrNotFound(T? value)
{
return value is null ? NotFound() : Ok(value);
}

protected IActionResult ListOrOk<T>(IEnumerable<T> values)
{
    return Ok(values ?? Enumerable.Empty<T>());
}

}

Usage:

public class UsersController : ApiControllerBase
{
[HttpGet("{id}")]
public async Task GetUser(Guid id)
{
var user = await _mediator.Send(new GetUserQuery(id));
return SingleOrNotFound(user);
}

[HttpGet]
public async Task<IActionResult> ListUsers()
{
    var users = await _mediator.Send(new ListUsersQuery());
    return ListOrOk(users);
}

}

This removes duplicated 404 logic across the entire API surface.


🧭 5. Optional Enhancement: Result Pattern

Queries may return a structured result:

public record Result(T? Value, bool IsNotFound);

Then the base controller handles it:

protected IActionResult FromResult(Result result)
{
if (result.IsNotFound)
return NotFound();

return Ok(result.Value);

}

This eliminates null checks entirely.


🧩 6. Summary of Unified Behavior

Layer Single Item Missing List Missing Notes
Domain/App null or failure result empty list No exceptions
REST API 404 200 + empty array Enforced via base controller
MCP Tools null empty array No HTTP semantics
Ports/Adapters null empty list Caller decides meaning

🚀 7. Action Items for Implementation

  1. Add ApiControllerBase with SingleOrNotFound and ListOrOk.
  2. Refactor all controllers to use the base class.
  3. Standardize domain queries to return T? or Result<T>.
  4. Update MCP tools and port/adapters to follow non-HTTP semantics.
  5. Document this behavior in the architecture handbook.
  6. Evaluate conditionals in Presentation.Web services that compensate for 404 by if/then skipping api calls based on data. Implement best practices here.

📌 Expected Outcomes

  • Zero duplicated 404 logic across controllers
  • Clean separation of domain vs transport semantics
  • Predictable behavior across REST, MCP, and internal modules
  • Reduced brittleness and developer cognitive load
  • A more maintainable, scalable modular monolith

📘 Addendum: Proper Use of CustomNotFoundException

This addendum clarifies when a CustomNotFoundException is appropriate within a Clean Architecture system and how it differs from normal “not found” outcomes in REST, MCP, and port/adapters.


✅ Guiding Principle

A CustomNotFoundException should be used only when “not found” represents a business rule violation or an impossible state, not when an entity simply doesn’t exist.

Most “not found” cases are normal and expected — and should not use exceptions.


🚫 When Not to Use CustomNotFoundException

These cases should never throw:

Normal Query Absence

  • Example: “GetUserById”
  • If the user doesn’t exist, this is normal.
  • Domain returns null or a Result.NotFound.
  • REST returns 404.
  • MCP returns null.
  • Ports return null.

Normal Command Absence

  • Example: “DeleteUser”, “UpdateOrder”
  • If the entity doesn’t exist, this is still normal.
  • Domain returns a failure result (not an exception).
  • REST maps to 404.
  • MCP returns a structured failure.
  • Ports return a failure result.

Absence is not exceptional. It is part of normal business flow.


🟦 When CustomNotFoundException Is Appropriate

Use a CustomNotFoundException only when absence violates a domain invariant or indicates corruption.

1. Business Rule Requires Existence

If the domain explicitly states the entity must exist for the operation:

  • “You cannot ship an order that doesn’t exist.”
  • “You cannot enroll a student who is not registered.”
  • “You cannot assign a task to a user who does not exist.”

In these cases, absence is a business rule violation, not a normal outcome.

2. Workflow Guarantees Existence

If a workflow step guarantees the entity was created earlier:

  • Saga step expects a previously created aggregate.
  • Domain event handler expects the aggregate to exist because the event guarantees it.

If it’s missing, something is wrong — throw.

3. Data Corruption or Impossible State

If “not found” indicates:

  • broken invariants
  • corrupted data
  • impossible domain state

Then a CustomNotFoundException is appropriate.


🧠 Rule of Thumb

If the user could reasonably cause the “not found” → NO EXCEPTION.
If the system guarantees the entity must exist → EXCEPTION.

This rule keeps the domain clean and prevents exception misuse.


🧭 Layer-by-Layer Behavior

Domain/Application

  • Normal absence → null or failure result
  • Exceptional absence → CustomNotFoundException

REST API

  • Normal absence → 404
  • Exceptional absence → 500 or 409 (depending on rule violation)

MCP Tools

  • Normal absence → null
  • Exceptional absence → structured error object

Ports/Adapters

  • Normal absence → null
  • Exceptional absence → domain exception (not HTTP)

📌 Summary

A CustomNotFoundException is not for normal “not found” cases.
It is reserved for invariant violations, workflow guarantees, and corruption scenarios.

This ensures:

  • predictable domain behavior
  • clean separation of concerns
  • consistent REST/MCP semantics
  • reduced brittleness
  • meaningful exception usage

This addendum should be applied alongside the global 404 handling guidelines.

📘 Addendum: Proper Use of CustomNotFoundException in Commands and Queries

This addendum clarifies when a CustomNotFoundException should be used inside command handlers and query handlers, and when it should not be used. This distinction is critical for maintaining clean architecture boundaries, predictable behavior, and low‑noise exception handling.


🎯 Core Principle

A CustomNotFoundException is not for normal “entity not found” cases.

It is only appropriate when absence violates a domain invariant or represents an impossible state.

Most command/query flows should not throw exceptions for missing entities.


🚫 When Not to Use CustomNotFoundException

1. Query Handlers (GetById, GetDetails, etc.)

Queries represent read operations.
Absence is normal and expected.

Correct behavior:

  • Return null or Result.NotFound
  • REST maps to 404
  • MCP returns null
  • Ports return null

Never throw.


2. Command Handlers Where Input Entity May Not Exist

Examples:

  • DeleteUser
  • UpdateOrder
  • PatchCustomer
  • CreateMessageForConversation (conversation may not exist)

In these cases, the user can reasonably supply an ID that doesn’t exist.
This is a valid business outcome, not an exceptional condition.

Correct behavior:

  • Return Result.Failure(NotFound)
  • REST maps to 404
  • MCP returns structured failure
  • Ports return failure result

Still no exception.


🟦 When CustomNotFoundException Is Appropriate

Use a CustomNotFoundException only when “not found” indicates a deeper problem than simple absence.

1. Domain Rule Requires Existence

If the domain explicitly states the entity must exist for the operation:

Examples:

  • “You cannot ship an order that doesn’t exist.”
  • “You cannot approve a loan application that was never created.”
  • “You cannot progress a workflow step if the aggregate is missing.”

Here, absence is a business rule violation, not a normal outcome.

2. Workflow Guarantees Existence

If a previous step guarantees

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions