Marks a GraphQL OBJECT type as the typed home for one or more Java exceptions. When a @service-returned payload exposes an errors: field of an @error type (or a union of @error types), the rewrite generates an error channel: at runtime the service method’s exception is caught, matched against the type’s handlers:, and the matched type is appended to the payload’s errors list.

@error is the entry point to Graphitron’s "errors-as-data" path. It contrasts with throwing: a thrown exception ends the resolver; a mapped exception flows through the GraphQL response as a typed error alongside the rest of the data.

SDL signature

directive @error(handlers: [ErrorHandler!]!) on OBJECT

input ErrorHandler {
    handler:     ErrorHandlerType!     # GENERIC | DATABASE | VALIDATION
    className:   String                # FQCN of the exception (required for GENERIC; rejected for DATABASE and VALIDATION)
    code:        String                # Vendor-specific DB error code (DATABASE only)
    sqlState:    String                # Standardized SQL state (DATABASE only)
    matches:     String                # Substring the exception message must contain
    description: String                # Message returned to the client instead of the exception's own (rejected for VALIDATION)
}

enum ErrorHandlerType { GENERIC, DATABASE, VALIDATION }

Parameters

Name Type Default Description

handlers

[ErrorHandler!]!

(required)

One or more handler entries describing which Java exceptions map to this error type. Multiple entries on a single type are an "OR" — any matching entry assigns the exception to this type. The first entry whose criteria match (across all @error types reachable from the same channel) wins.

ErrorHandler fields:

Field Required Description

handler

yes

Selector. DATABASE matches any java.sql.SQLException in the cause chain, narrowed by sqlState/code/matches; jOOQ’s DataAccessException routes because it carries the driver’s SQLException as its cause. GENERIC matches by className plus optional matches. VALIDATION marks the channel for a Bean Validation pre-execution step.

className

for GENERIC

Fully qualified exception class name. Required for GENERIC; rejected for DATABASE (which matches any SQLException, so use GENERIC when you want class-narrowed matching) and rejected for VALIDATION.

code

no (DATABASE only)

Vendor-specific error code as returned by SQLException.getErrorCode(). PostgreSQL always returns 0; Oracle uses meaningful codes.

sqlState

no (DATABASE only)

Standardized SQL state from SQLException.getSQLState() (e.g. "23503" for foreign-key violation, "23514" for check-constraint violation).

matches

no

Substring the exception message must contain. Useful to narrow a broad className or sqlState to one specific error.

description

no

The message the client reads on message:, in place of the matched exception’s own getMessage(). Absent, message: reads the source’s message. Rejected on a VALIDATION handler: that path emits one error per constraint violation, each carrying that violation’s own interpolated message, so a single client-facing string belongs on the constraint annotation’s own message attribute instead.

description: only changes what message: reads. The matched exception itself is what lands in the payload’s errors list, so extra fields keep reading the live exception and a union still resolves __typename from the exception’s own class.

Canonical example

A service payload with a typed errors channel:

# FilmPayload is backed by com.example.FilmPayload, the return type of the
# createFilm @service method below; no directive declares the binding.
type FilmPayload {
    film:   Film
    errors: [FilmError]
}

union FilmError = YearOutOfRange | NotAllowed | DbError

type YearOutOfRange @error(handlers: [
    {handler: DATABASE, sqlState: "23514", matches: "year_check",
     description: "Release year must be between 1901 and 2155"}
]) {
    path:    [String!]!
    message: String!
}

type NotAllowed @error(handlers: [
    {handler: GENERIC, className: "com.example.NotAllowedException",
     description: "You are not allowed to do this"}
]) {
    path:    [String!]!
    message: String!
}

type DbError @error(handlers: [
    {handler: DATABASE, sqlState: "23503"}
]) {
    path:    [String!]!
    message: String!
}

type Mutation {
    createFilm(input: CreateFilmInput!): FilmPayload @service(service: {className: "com.example.FilmService"})
}

When the service method throws an exception carrying a java.sql.SQLException whose getSQLState() is 23514 and whose message contains year_check (jOOQ’s DataAccessException wrapping the driver’s exception is the usual shape), the rewrite catches it, resolves the YearOutOfRange type with message = description (or the exception’s own message when description: is absent), and appends the matched exception to FilmPayload.errors. The data field still streams to the client; only film is null for that request.

VALIDATION handles a Bean-Validation failure raised before the service method runs. It takes no other field on the entry, description: included:

type ValidationErr @error(handlers: [{handler: VALIDATION}]) {
    path:    [String!]!
    message: String!
}

Extra fields

Beyond the required path and message, an @error type may declare extra fields. Each extra field is read from the matched exception (or, for VALIDATION, the GraphQLError) through an accessor matching the field name: code: String reads getCode(), code(), or a public code field. When the Java accessor name diverges from the GraphQL field name, @field(name:) names the accessor to use instead:

type FilmLookupInvalid @error(handlers: [{
        handler: GENERIC,
        className: "no.sikt.graphitron.rewrite.test.services.FilmLookupInvalidIdException"
    }]) {
    path: [String!]!
    message: String!
    attempted: Int @field(name: "attemptedId")   # reads getAttemptedId()
}

The build fails when a declared extra field cannot be populated from every handler’s source class, listing the accessors the class does expose and naming the type the accessor had to return. @field on path or message is rejected: those two fields are populated by Graphitron itself.

Publishing a node id from an extra field

An extra field carrying @nodeId(typeName:) publishes a global object ID built from what the read yielded, so the exception carries the key and the client sees the ID:

type FilmLookupInvalid @error(handlers: [{
        handler: GENERIC,
        className: "no.sikt.graphitron.rewrite.test.services.FilmLookupInvalidIdException"
    }]) {
    path: [String!]!
    message: String!
    attempted: Int @field(name: "attemptedId")                          # the raw key
    attemptedFilm: ID @nodeId(typeName: "Film") @field(name: "attemptedId")  # the same value as a node id
}

Both fields read getAttemptedId(). The accessor therefore has to return film_id’s own Java type, not a `String: the encode happens on the way out, so an accessor that already returns an encoded ID is a build error naming the type it should have returned. If you were encoding by hand to publish an ID here, that call site goes away and the accessor exposes the key.

typeName: is required, and the node type’s key must be one column. An @error type stands for no table, so there is nothing for a bare @nodeId to inherit its node from; and the field is one value, so a node type keyed on several columns has nothing to be encoded from and the build says so, naming the count.

Constraints

  • @error only takes effect when the type appears as (or in a union behind) an errors: field on a payload reachable from a @service field. A standalone @error type with no carrier is rejected at classify time.

  • Declaration order decides, and the first match wins. Handlers are walked in SDL order (the channel’s @error types in declaration order, and within a type its handlers: in array order), so when two handlers both match one throwable the one declared first is the answer. Declare the narrow handler before the broad one.

  • Nothing sorts by specificity. matches: makes two otherwise-identical handlers distinct: (GENERIC, IllegalArgumentException, matches: "foo") and (GENERIC, IllegalArgumentException) (no matches) are both reachable, but the narrowed one runs first only if you declared it first. Declaring the broad one first leaves the narrow one silently unreachable.

  • Two handlers of the same variant with identical criteria (same handler, className, code, sqlState, matches) are rejected: the second would be unreachable. Overlap across variants is allowed, since a GENERIC handler and a DATABASE handler inspect orthogonal fields.

  • For GENERIC, className: is required. The build rejects a GENERIC handler without it.

  • For DATABASE, supply at least one of code:, sqlState:, or matches: (otherwise every SQLException on the channel routes to this type, which is rarely the intent).

  • code: and sqlState: cannot be combined: the build rejects a DATABASE entry carrying both, since the two discriminators are vendor-conflicting. Split into two entries, one per discriminator. Vendor portability: prefer sqlState (PostgreSQL is specific, Oracle is generic), narrowed with a matches: substring where needed.

  • An extra field carrying @nodeId needs typeName: and a node type keyed on a single column; see Publishing a node id from an extra field.

  • Applies only to OBJECT. Interfaces and unions cannot carry @error directly — the union-of-error-types pattern works because the union members are individually @error-decorated.

See also

  • @service is the upstream of the errors channel: only service-returned payloads carry one.

  • The payload type that exposes the errors: field is class-backed, its backing class inferred from the @service method’s return type.

  • How-to: The errors channel covers union vs single-type carriers, how message: resolves between description: and the source’s own message, and the path: slot’s contents.