Error Handling
GraphQL APIs produce two kinds of errors. Request errors occur when something goes wrong during execution, such as an unhandled exception in a resolver. Domain errors represent business logic rejections, such as a username already being taken or an invalid input value. Hot Chocolate handles both, with different mechanisms for each.
Request errors appear in the top-level errors array of the GraphQL response. Domain errors, when using mutation conventions, appear as typed error objects on the mutation payload. This guide covers both patterns in depth.
Request Errors
When a resolver throws an unhandled exception, Hot Chocolate catches it and does two things: the field returns null, and an error entry appears in the errors array of the response.
By default, exception details are hidden in production. Instead of exposing the original exception message, the response contains a generic "Unexpected Execution Error" message. This prevents leaking internal implementation details to clients.
{ "data": { "userById": null }, "errors": [ { "message": "Unexpected Execution Error", "locations": [{ "line": 2, "column": 3 }], "path": ["userById"] } ]}
During development, if a debugger is attached, Hot Chocolate includes the original exception message and stack trace. You can also enable this behavior explicitly:
// Program.csbuilder .AddGraphQL() .ModifyRequestOptions(opt => opt.IncludeExceptionDetails = true);
Warning: Do not enable
IncludeExceptionDetailsin production. Exception messages and stack traces can expose sensitive information about your application internals.
Error Filters
An error filter lets you intercept every error before it reaches the client. Use error filters to log the original exception, sanitize the error message, or add error codes.
Register an error filter with AddErrorFilter. The filter receives an IError and must return an IError. You can modify the error using its With* methods, which return a new IError instance with the changed property.
// Program.csbuilder .AddGraphQL() .AddErrorFilter(error => { if (error.Exception is not null) { // Log the original exception for debugging Console.Error.WriteLine(error.Exception);
return error .WithMessage("An internal error occurred. Please try again later.") .WithCode("INTERNAL_ERROR"); }
return error; });
For more complex scenarios, implement the IErrorFilter interface as a class. This lets you inject services such as a logger.
// Infrastructure/LoggingErrorFilter.cspublic class LoggingErrorFilter : IErrorFilter{ private readonly ILogger<LoggingErrorFilter> _logger;
public LoggingErrorFilter(ILogger<LoggingErrorFilter> logger) { _logger = logger; }
public IError OnError(IError error) { if (error.Exception is not null) { _logger.LogError(error.Exception, "Unhandled exception in resolver.");
return error .WithMessage("An internal error occurred.") .WithCode("INTERNAL_ERROR") .WithException(null); // strip the exception from the error }
return error; }}
// Program.csbuilder .AddGraphQL() .AddErrorFilter<LoggingErrorFilter>();
Multiple error filters can be registered. They run in the order they are added, and each filter receives the output of the previous one.
Error Codes
The IError interface supports a Code property, which appears under extensions.code in the GraphQL response. Error codes let clients handle specific error conditions programmatically without parsing messages.
{ "errors": [ { "message": "An internal error occurred.", "locations": [{ "line": 2, "column": 3 }], "path": ["userById"], "extensions": { "code": "INTERNAL_ERROR" } } ]}
Set error codes in an error filter using WithCode, or build errors with codes from scratch using ErrorBuilder:
var error = ErrorBuilder.New() .SetMessage("Rate limit exceeded.") .SetCode("RATE_LIMITED") .Build();
Domain Errors with Mutation Conventions
Domain errors are the primary mechanism for communicating business logic failures to clients. When mutation conventions are enabled, you annotate mutations with [Error] attributes. Hot Chocolate catches the declared exception types and maps them to typed error objects on the mutation payload.
This keeps domain errors separate from request errors: they appear on the payload, not in the top-level errors array, and clients can query them with specific fields and types.
For mutation conventions setup, see Mutations.
Map Exceptions Directly
The most straightforward approach is to annotate the mutation with the exception type. The exception's Message property becomes the error message.
// Exceptions/UserNameTakenException.cspublic class UserNameTakenException : Exception{ public UserNameTakenException(string username) : base($"The username '{username}' is already taken.") { Username = username; }
public string Username { get; }}
// Types/UserMutations.cs[MutationType]public static partial class UserMutations{ [Error(typeof(UserNameTakenException))] [Error(typeof(InvalidUserNameException))] public static async Task<User?> UpdateUserNameAsync( [ID] Guid userId, string username, UserService users, CancellationToken ct) => await users.UpdateNameAsync(userId, username, ct);}
Hot Chocolate rewrites the exception class name for the schema: UserNameTakenException becomes UserNameTakenError. The generated schema looks like this:
type UpdateUserNamePayload { user: User errors: [UpdateUserNameError!]}
union UpdateUserNameError = UserNameTakenError | InvalidUserNameError
type UserNameTakenError implements Error { message: String!}
interface Error { message: String!}
Map with a Factory Method
When you need control over the error shape, or want to hide internal details from the exception, create a dedicated error class with a static CreateErrorFrom method. Hot Chocolate discovers this method by convention.
// Errors/UserNameTakenError.cspublic class UserNameTakenError{ private UserNameTakenError(string message, string username) { Message = message; Username = username; }
public string Message { get; }
public string Username { get; }
public static UserNameTakenError CreateErrorFrom(UserNameTakenException ex) => new($"The username '{ex.Username}' is already taken.", ex.Username);}
Then reference the error class instead of the exception:
// Types/UserMutations.cs[MutationType]public static partial class UserMutations{ [Error(typeof(UserNameTakenError))] public static async Task<User?> UpdateUserNameAsync( [ID] Guid userId, string username, UserService users, CancellationToken ct) => await users.UpdateNameAsync(userId, username, ct);}
A single error class can handle multiple exception types by defining multiple CreateErrorFrom overloads:
// Errors/UserValidationError.cspublic class UserValidationError{ private UserValidationError(string message) => Message = message;
public string Message { get; }
public static UserValidationError CreateErrorFrom(UserNameTakenException ex) => new($"The username '{ex.Username}' is already taken.");
public static UserValidationError CreateErrorFrom(InvalidUserNameException ex) => new($"The username is invalid: {ex.Reason}");}
Map with a Constructor
Alternatively, give the error class a constructor that accepts the exception.
// Errors/UserNameTakenError.cspublic class UserNameTakenError{ public UserNameTakenError(UserNameTakenException ex) { Message = $"The username '{ex.Username}' is already taken."; Username = ex.Username; }
public string Message { get; }
public string Username { get; }}
Factory with Dependency Injection
For error factories that need access to services (such as a localizer or a logger), implement the IPayloadErrorFactory<TException, TError> interface. Hot Chocolate resolves the factory from the DI container.
// Errors/UserNameTakenErrorFactory.cspublic class UserNameTakenErrorFactory : IPayloadErrorFactory<UserNameTakenException, UserNameTakenError>{ private readonly IStringLocalizer<UserErrors> _localizer;
public UserNameTakenErrorFactory(IStringLocalizer<UserErrors> localizer) { _localizer = localizer; }
public UserNameTakenError CreateErrorFrom(UserNameTakenException exception) => new(_localizer["UserNameTaken", exception.Username]);}
Register the factory in the DI container:
// Program.csbuilder.Services .AddSingleton<IPayloadErrorFactory<UserNameTakenException, UserNameTakenError>, UserNameTakenErrorFactory>();
Returning Multiple Errors
A mutation can return multiple domain errors at once by throwing an AggregateException. Hot Chocolate unwraps it and maps each inner exception to its corresponding error type.
// Services/UserService.cspublic async Task<User> UpdateNameAsync( Guid userId, string username, CancellationToken ct){ var errors = new List<Exception>();
if (username.Length < 3) errors.Add(new InvalidUserNameException("Must be at least 3 characters."));
if (await IsUserNameTakenAsync(username, ct)) errors.Add(new UserNameTakenException(username));
if (errors.Count > 0) throw new AggregateException(errors);
// ... proceed with update}
Sharing Errors Across Mutations
Error classes and error factories are not tied to a specific mutation. You can reuse the same [Error(typeof(...))] annotation across multiple mutation methods. This keeps your error types consistent and avoids duplication.
[MutationType]public static partial class UserMutations{ [Error(typeof(UserNameTakenError))] public static async Task<User?> UpdateUserNameAsync(/* ... */) { /* ... */ }
[Error(typeof(UserNameTakenError))] public static async Task<User?> CreateUserAsync(/* ... */) { /* ... */ }}
Custom Error Interface
By default, mutation convention errors implement an Error interface with a single message field. You can replace this interface to require additional fields such as code.
// Types/IUserError.cs[GraphQLName("UserError")]public interface IUserError{ string Message { get; } string Code { get; }}
// Program.csbuilder .AddGraphQL() .AddMutationConventions(applyToAllMutations: true) .AddErrorInterfaceType<IUserError>();
All error types must declare every field required by the interface. They do not need to implement the C# interface, but they must have matching properties.
// Errors/UserNameTakenError.cspublic class UserNameTakenError{ public UserNameTakenError(UserNameTakenException ex) { Message = $"The username '{ex.Username}' is already taken."; Code = "USERNAME_TAKEN"; }
public string Message { get; } public string Code { get; }}
The generated schema now requires both fields on every error type:
interface UserError { message: String! code: String!}
type UserNameTakenError implements UserError { message: String! code: String!}
Errors Outside Mutations
Query and subscription resolvers do not use mutation conventions, so domain errors work differently. You have several options.
Report an Error and Return Null
Use ReportError on IResolverContext to add an error to the response while still returning data (or null) from the resolver. The error appears in the top-level errors array.
// Types/UserQueries.cs[QueryType]public static partial class UserQueries{ public static User? GetUserByEmail( string email, UserService users, IResolverContext context) { var user = users.FindByEmail(email);
if (user is null) { context.ReportError( ErrorBuilder.New() .SetMessage($"No user found with email '{email}'.") .SetCode("USER_NOT_FOUND") .Build()); return null; }
return user; }}
ReportError has three overloads:
ReportError(string errorMessage)for quick error messages.ReportError(IError error)for fully constructed error objects.ReportError(Exception exception, Action<ErrorBuilder>? configure)for reporting caught exceptions with optional customization.
Use a Result Union
For queries where you need typed error handling similar to mutation conventions, return a union type. The client can then use inline fragments to handle each case.
// Types/UserQueries.cs[QueryType]public static partial class UserQueries{ public static IUserByEmailResult GetUserByEmail( string email, UserService users) { var user = users.FindByEmail(email);
if (user is null) return new UserNotFoundError($"No user found with email '{email}'.");
return user; }}
// Types/UserNotFoundError.cspublic record UserNotFoundError(string Message);
// Types/IUserByEmailResult.cs[UnionType("UserByEmailResult")]public interface IUserByEmailResult;
// Make User and UserNotFoundError implement the interfacepublic partial class User : IUserByEmailResult { }public partial record UserNotFoundError : IUserByEmailResult;
Next Steps
- Need mutation conventions? See Mutations for the full pattern including inputs, payloads, and naming customization.
- Need to build a schema? See Schema Basics for an overview of how types, queries, and mutations fit together.
- Need to fetch data? See DataLoader for efficient data fetching patterns.