Custom exceptions let you give domain-specific names to error conditions in your application. Instead of throwing a vague InvalidOperationException("Post not found"), you throw a NotFoundException("Post", id). The name carries meaning, the exception can carry structured data (error codes, affected entity details), and the global exception handler in ASP.NET Core can map specific exception types to specific HTTP status codes — NotFoundException → 404, ValidationException → 422, ConflictException → 409 — without any try/catch in your controllers.
The Three-Constructor Convention
// Custom exception — always implement these three constructors
// This satisfies serialisation requirements and exception chaining
[Serializable]
public class NotFoundException : Exception
{
public string EntityName { get; }
public object EntityId { get; }
// Most useful constructor — carries structured data
public NotFoundException(string entityName, object entityId)
: base($"{entityName} with ID '{entityId}' was not found.")
{
EntityName = entityName;
EntityId = entityId;
}
// Required for exception chaining
public NotFoundException(string entityName, object entityId, Exception inner)
: base($"{entityName} with ID '{entityId}' was not found.", inner)
{
EntityName = entityName;
EntityId = entityId;
}
// Required for serialisation (binary serialisation, remoting)
protected NotFoundException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context)
: base(info, context)
{
EntityName = info.GetString(nameof(EntityName)) ?? string.Empty;
EntityId = info.GetValue(nameof(EntityId), typeof(object)) ?? 0;
}
}
SerializationInfo constructor. If your custom exception is ever serialised (which happens in distributed scenarios, WCF, or when exceptions are logged as structured data), the serialisation constructor is needed. In modern ASP.NET Core APIs that do not use binary serialisation, the third constructor is less critical — but it is good practice to include it for completeness and future-proofing.AppException) with common properties like ErrorCode and derive all your domain exceptions from it. The global exception handler can then catch AppException and use the ErrorCode to produce structured error responses. This pattern — a single catch for all domain exceptions that dispatches based on type or code — is far cleaner than having a catch block for each exception type in middleware.A Domain Exception Hierarchy
// Base domain exception — all app-specific exceptions derive from this
public abstract class AppException : Exception
{
public string ErrorCode { get; }
protected AppException(string errorCode, string message)
: base(message) => ErrorCode = errorCode;
protected AppException(string errorCode, string message, Exception inner)
: base(message, inner) => ErrorCode = errorCode;
}
// 404 Not Found
public class NotFoundException : AppException
{
public NotFoundException(string entity, object id)
: base("NOT_FOUND", $"{entity} '{id}' was not found.") { }
}
// 409 Conflict
public class ConflictException : AppException
{
public ConflictException(string message)
: base("CONFLICT", message) { }
}
// 422 Unprocessable Entity (validation failure)
public class ValidationException : AppException
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("VALIDATION_ERROR", "One or more validation errors occurred.")
=> Errors = errors;
}
// 403 Forbidden
public class ForbiddenException : AppException
{
public ForbiddenException(string message = "Access denied.")
: base("FORBIDDEN", message) { }
}
// Usage in service layer
public async Task<Post> GetPostAsync(int id)
{
var post = await _repo.GetByIdAsync(id)
?? throw new NotFoundException(nameof(Post), id);
return post;
}
public async Task PublishAsync(int id, string requestingUserId)
{
var post = await GetPostAsync(id);
if (post.AuthorId != requestingUserId)
throw new ForbiddenException("You can only publish your own posts.");
if (post.IsPublished)
throw new ConflictException("Post is already published.");
post.IsPublished = true;
await _repo.UpdateAsync(post);
}
Common Mistakes
Mistake 1 — Not including InnerException in custom exception constructors
❌ Wrong — wrapping an exception without preserving the original loses the root cause:
catch (SqlException)
{
throw new DataAccessException("DB error"); // original SqlException lost!
}
✅ Correct — always pass the original as InnerException when wrapping:
catch (SqlException ex)
{
throw new DataAccessException("DB error", ex); // ✓ SqlException preserved
}
Mistake 2 — Creating a custom exception for every minor error condition
❌ Wrong — dozens of single-use exception types create noise with no benefit.
✅ Correct — create custom exceptions when you need HTTP status code mapping, structured data, or domain layer distinction.