📘 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();
}
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
- Add
ApiControllerBase with SingleOrNotFound and ListOrOk.
- Refactor all controllers to use the base class.
- Standardize domain queries to return
T? or Result<T>.
- Update MCP tools and port/adapters to follow non-HTTP semantics.
- Document this behavior in the architecture handbook.
- 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
📘 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
T?for single-item queriesIEnumerable<T>for collectionsThis prevents HTTP semantics from leaking into core logic.
🌐 2. REST API Behavior (Transport Semantics)
Single GET
null, the API returns 404 Not Found.Collection GET
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
nullfor missing single items.Port/Adapter Interfaces
GetByIdPortreturnsT?.ListPortreturnsIEnumerable<T>.Result<T>for explicit outcomes.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);
}
}
Usage:
public class UsersController : ApiControllerBase
{
[HttpGet("{id}")]
public async Task GetUser(Guid id)
{
var user = await _mediator.Send(new GetUserQuery(id));
return SingleOrNotFound(user);
}
}
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();
}
This eliminates null checks entirely.
🧩 6. Summary of Unified Behavior
🚀 7. Action Items for Implementation
ApiControllerBasewithSingleOrNotFoundandListOrOk.T?orResult<T>.📌 Expected Outcomes
📘 Addendum: Proper Use of
CustomNotFoundExceptionThis addendum clarifies when a
CustomNotFoundExceptionis appropriate within a Clean Architecture system and how it differs from normal “not found” outcomes in REST, MCP, and port/adapters.✅ Guiding Principle
A
CustomNotFoundExceptionshould 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
CustomNotFoundExceptionThese cases should never throw:
Normal Query Absence
nullor aResult.NotFound.null.null.Normal Command Absence
Absence is not exceptional. It is part of normal business flow.
🟦 When
CustomNotFoundExceptionIs AppropriateUse a
CustomNotFoundExceptiononly 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:
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:
If it’s missing, something is wrong — throw.
3. Data Corruption or Impossible State
If “not found” indicates:
Then a
CustomNotFoundExceptionis 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
nullor failure resultCustomNotFoundExceptionREST API
MCP Tools
nullPorts/Adapters
null📌 Summary
A
CustomNotFoundExceptionis not for normal “not found” cases.It is reserved for invariant violations, workflow guarantees, and corruption scenarios.
This ensures:
This addendum should be applied alongside the global 404 handling guidelines.
📘 Addendum: Proper Use of
CustomNotFoundExceptionin Commands and QueriesThis addendum clarifies when a
CustomNotFoundExceptionshould 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
CustomNotFoundExceptionis 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
CustomNotFoundException1. Query Handlers (GetById, GetDetails, etc.)
Queries represent read operations.
Absence is normal and expected.
Correct behavior:
nullorResult.NotFoundnullnullNever throw.
2. Command Handlers Where Input Entity May Not Exist
Examples:
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:
Result.Failure(NotFound)Still no exception.
🟦 When
CustomNotFoundExceptionIs AppropriateUse a
CustomNotFoundExceptiononly 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:
Here, absence is a business rule violation, not a normal outcome.
2. Workflow Guarantees Existence
If a previous step guarantees