Repository: mercurius-js/mercurius
Pull request #385 (2021-01-29 11:29:47)

Add hooks to Mercurius
Closes: https://github.com/mercurius-js/mercurius/issues/380

projectjudge: Thanks! I have tested on a very basic level and seems to be playing okay. I'll write some thorough tests for subscriptions for both normal server and gateway mode to make sure it is all working as expected :) I also noticed that the CI checks are intermittently failing for some of the subscription tests. At first glance it seems to be an environmental issue (other checks for the same node version pass fine) - did you have any thoughts on this? (Example run: https://github.com/mercurius-js/mercurius/pull/385/checks?check_run_id=1791951386)
mcollina: I've seen the flaky tests... can't do much about it right now. If you'd like to investigate and fix them, I'd be very grateful!
projectjudge: For sure, very happy to take a look at the tests! :) 
projectjudge: > Could you link the new docs pages from the README as well?

Sure thing!

> Good work! How do the hooks play with subscriptions?

After a bit of experimentation, I think we need to add a few more hook events within `lib/subscription-connection.js`. From my observations with subscriptions so far, I have seen that `preGatewayExecution` is executed when using federated subscriptions. We don't see any other hooks triggered because subscriptions don't go through the normal request route. To fix this, I suggest we add `preParsing`, `preExecution` and `onResolution` (not `preValidation` because we don't do any validation in subscriptions) to `lib/subscription-connection.js` to make sure we capture the full GraphQL lifecycle for subscriptions.

My only concern with this approach is the meaning of context when applied to the hooks. Because it is not strictly associated with a request over the wire, one could argue that we should have subscription specific hooks for this situation instead. I wondered what your thoughts were on this?

Ps, apologies for my slower than normal responses, I'm on holiday this week! :)

Patch:
diff --git a/README.md b/README.md
index 56abe87..7346c44 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,8 @@ The previous name of this module was [fastify-gql](http://npm.im/fastify-gql) (<
   - [makeExecutableSchema support](#makeexecutableschema-support)
 - [Context](#context)
 - [API](#api)
+- [Lifecycle](#lifecycle)
+- [Hooks](#hooks)
 - [Federation](#federation)
 - [Subscriptions](#subscriptions)
 - [Batched Queries](#batched-queries)
@@ -120,6 +122,14 @@ app.listen(3000)
 
 [More info](docs/api/options.md)
 
+## Lifecycle
+
+[More info](docs/lifecycle.md)
+
+## Hooks
+
+[More info](docs/hooks.md)
+
 ## Federation
 
 [More info](docs/federation.md)
diff --git a/docs/hooks.md b/docs/hooks.md
new file mode 100644
index 0000000..1b6c0a4
--- /dev/null
+++ b/docs/hooks.md
@@ -0,0 +1,144 @@
+# Hooks
+
+Hooks are registered with the `fastify.graphql.addHook` method and allow you to listen to specific events in the GraphQL request/response lifecycle. You have to register a hook before the event is triggered, otherwise the event is lost.
+
+By using hooks you can interact directly with the GraphQL lifecycle of Mercurius. There are GraphQL Request hooks:
+
+- [GraphQL Request Hooks](#graphql-request-hooks)
+  - [preParsing](#preparsing)
+  - [preValidation](#prevalidation)
+  - [preExecution](#preexecution)
+  - [preGatewayExecution](#pregatewayexecution)
+  - [onResolution](#onresolution)
+  - [Manage Errors from a hook](#manage-errors-from-a-hook)
+  - [Add errors to the GraphQL response from a hook](#add-errors-to-the-graphql-response-from-a-hook)
+
+**Notice:** these hooks are only supported with `async`/`await` or returning a `Promise`.
+
+## GraphQL Request Hooks
+
+It is pretty easy to understand where each hook is executed by looking at the [lifecycle page](lifecycle.md).<br>
+
+There are four different hooks that you can use in GraphQL Request *(in order of execution)*:
+
+When registering hooks, you must wait for Mercurius to be registered in Fastify.
+
+```js
+await fastify.ready()
+```
+
+### preParsing
+
+If you are using the `preParsing` hook, you can access the GraphQL query string before it is parsed. It receives the schema and context objects as other hooks.
+
+For instance, you can register some tracing events:
+
+```js
+fastify.graphql.addHook('preParsing', async (schema, source, context) => {
+  await registerTraceEvent()
+})
+```
+
+### preValidation
+
+By the time the `preValidation` hook triggers, the query string has been parsed into a GraphQL Document AST.
+
+```js
+fastify.addHook('preValidation', async (schema, document, context) => {
+  await asyncMethod()
+})
+```
+
+### preExecution
+
+In the `preExecution` hook, you can modify the following items by returning them in the hook definition:
+  - `document`
+  - `errors`
+
+```js
+fastify.addHook('preExecution', async (schema, document, context) => {
+  const { modifiedDocument, errors } = await asyncMethod(document)
+
+  return {
+    document: modifiedDocument
+    errors
+  }
+})
+```
+
+### preGatewayExecution
+
+In the `preGatewayExecution` hook, you can modify the following items by returning them in the hook definition:
+  - `document`
+  - `errors`
+
+This hook will only be triggered in gateway mode. When in gateway mode, each hook definition will trigger multiple times in a single request just before executing remote GraphQL queries on the federated services.
+
+```js
+fastify.addHook('preGatewayExecution', async (schema, document, context) => {
+  const { modifiedDocument, errors } = await asyncMethod(document)
+
+  return {
+    document: modifiedDocument
+    errors
+  }
+})
+```
+
+### onResolution
+
+```js
+fastify.addHook('onResolution', async (execution, context) => {
+  await asyncMethod()
+})
+```
+
+### Manage Errors from a hook
+If you get an error during the execution of your hook, you can just throw an error and Mercurius will automatically close the GraphQL request and send the appropriate errors to the user.`
+
+**Notice:** there is one exception to this with the `preGatewayExecution` hook, which will continue execution of the rest of the query and append the error to the errors array in the response.
+
+```js
+fastify.addHook('preParsing', async (schema, source, context) => {
+  throw new Error('Some error')
+})
+```
+
+### Add errors to the GraphQL response from a hook
+
+The following hooks support adding errors to the GraphQL response. These are:
+
+ - `preExecution`
+ - `preGatewayExecution`
+
+```js
+fastify.addHook('preExecution', async (schema, document, context) => {
+  return {
+    errors: [new Error('foo')]
+  }
+})
+
+fastify.addHook('preExecution', async (schema, document, context) => {
+  return {
+    errors: [new Error('bar')]
+  }
+})
+```
+
+Note, the original query will still execute. Adding the above will result in the following response:
+
+```json
+{
+  "data": {
+    foo: "bar"
+  },
+  "errors": [
+    {
+      "message": "foo"
+    },
+    {
+      "message": "bar"
+    }
+  ]
+}
+```
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
new file mode 100644
index 0000000..4a340a6
--- /dev/null
+++ b/docs/lifecycle.md
@@ -0,0 +1,57 @@
+# Lifecycle
+
+The schema of the internal lifecycle of Mercurius.<br>
+
+On the right branch of every section there is the next phase of the lifecycle, on the left branch there is the corresponding GraphQL error(s) that will be generated if the parent throws an error *(note that all the errors are automatically handled by Mercurius)*.
+
+## Normal lifecycle
+
+```
+Incoming GraphQL Request
+  │
+  └─▶ Routing
+           │
+  errors ◀─┴─▶ preParsing Hook
+                  │
+         errors ◀─┴─▶ Parsing
+                        │
+               errors ◀─┴─▶ preValidation Hook
+                               │
+                      errors ◀─┴─▶ Validation
+                                     │
+                            errors ◀─┴─▶ preExecution Hook
+                                            │
+                                   errors ◀─┴─▶ Execution
+                                                  │
+                                         errors ◀─┴─▶ Resolution
+                                                        │
+                                                        └─▶ onResolution Hook
+```
+
+## Gateway lifecycle
+
+```
+Incoming GraphQL Request
+  │
+  └─▶ Routing
+           │
+  errors ◀─┴─▶ preParsing Hook
+                  │
+         errors ◀─┴─▶ Parsing
+                        │
+               errors ◀─┴─▶ preValidation Hook
+                               │
+                      errors ◀─┴─▶ Validation
+                                     │
+                            errors ◀─┴─▶ preExecution Hook
+                                            │
+                                   errors ◀─┴─▶ Execution
+                                                  │
+                                                  └─▶ preGatewayExecution Hook(s) (appends errors only)
+                                                         │
+                                                errors ◀─┴─▶ GatewayExecution(s)
+                                                               │
+                                                      errors ◀─┴─▶ Resolution (once all GatewayExecutions have finished)
+                                                                     │
+                                                                     └─▶ onResolution Hook
+```
\ No newline at end of file
diff --git a/docsify/sidebar.md b/docsify/sidebar.md
index bab4dcf..264079b 100644
--- a/docsify/sidebar.md
+++ b/docsify/sidebar.md
@@ -12,6 +12,8 @@
   * [Error extensions](/docs/api/options#use-errors-extension-to-provide-additional-information-to-query-errors)
 * [Context](/docs/context)
 * [Loaders](/docs/loaders)
+* [Hooks](/docs/hooks)
+* [Lifecycle](/docs/lifecycle)
 * [Federation](/docs/federation)
 * [Subscriptions](/docs/subscriptions)
 * [Batched Queries](/docs/batched-queries)
diff --git a/examples/hooks-gateway.js b/examples/hooks-gateway.js
new file mode 100644
index 0000000..29f2261
--- /dev/null
+++ b/examples/hooks-gateway.js
@@ -0,0 +1,222 @@
+'use strict'
+const Fastify = require('fastify')
+const mercurius = require('..')
+const { ErrorWithProps } = mercurius
+
+async function createService (port, schema, resolvers = {}) {
+  const service = Fastify()
+
+  service.register(mercurius, {
+    schema,
+    resolvers,
+    federationMetadata: true,
+    graphiql: true,
+    jit: 1
+  })
+  await service.listen(port)
+}
+
+const users = {
+  u1: {
+    id: 'u1',
+    name: 'John'
+  },
+  u2: {
+    id: 'u2',
+    name: 'Jane'
+  },
+  u3: {
+    id: 'u3',
+    name: 'Jack'
+  }
+}
+
+const posts = {
+  p1: {
+    pid: 'p1',
+    title: 'Post 1',
+    content: 'Content 1',
+    authorId: 'u1'
+  },
+  p2: {
+    pid: 'p2',
+    title: 'Post 2',
+    content: 'Content 2',
+    authorId: 'u2'
+  },
+  p3: {
+    pid: 'p3',
+    title: 'Post 3',
+    content: 'Content 3',
+    authorId: 'u1'
+  },
+  p4: {
+    pid: 'p4',
+    title: 'Post 4',
+    content: 'Content 4',
+    authorId: 'u2'
+  }
+}
+
+async function start () {
+  await createService(4001, `
+    extend type Query {
+      me: User
+      you: User
+      hello: String
+    }
+
+    type User @key(fields: "id") {
+      id: ID!
+      name: String!
+      fullName: String
+      avatar(size: AvatarSize): String
+      friends: [User]
+    }
+
+    enum AvatarSize {
+      small
+      medium
+      large
+    }
+  `, {
+    Query: {
+      me: (root, args, context, info) => {
+        return users.u1
+      },
+      you: (root, args, context, info) => {
+        throw new ErrorWithProps('Can\'t fetch other users data', { code: 'NOT_ALLOWED' })
+      },
+      hello: () => 'world'
+    },
+    User: {
+      __resolveReference: (user, args, context, info) => {
+        return users[user.id]
+      },
+      avatar: (user, { size }) => `avatar-${size}.jpg`,
+      friends: (user) => Object.values(users).filter(u => u.id !== user.id),
+      fullName: (user) => user.name + ' Doe'
+    }
+  })
+
+  await createService(4002, `
+    type Post @key(fields: "pid") {
+      pid: ID!
+      title: String
+      content: String
+      author: User @requires(fields: "pid title")
+    }
+
+    type Query @extends {
+      topPosts(count: Int): [Post]
+    }
+
+    type User @key(fields: "id") @extends {
+      id: ID! @external
+      name: String @external
+      posts: [Post]
+      numberOfPosts: Int @requires(fields: "id name")
+    }
+
+    extend type Mutation {
+      createPost(post: PostInput!): Post
+      updateHello: String
+    }
+
+    input PostInput {
+      title: String!
+      content: String!
+      authorId: String!
+    }
+  `, {
+    Post: {
+      __resolveReference: (post, args, context, info) => {
+        return posts[post.pid]
+      },
+      author: (post, args, context, info) => {
+        return {
+          __typename: 'User',
+          id: post.authorId
+        }
+      }
+    },
+    User: {
+      posts: (user, args, context, info) => {
+        return Object.values(posts).filter(p => p.authorId === user.id)
+      },
+      numberOfPosts: (user) => {
+        return Object.values(posts).filter(p => p.authorId === user.id).length
+      }
+    },
+    Query: {
+      topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count)
+    },
+    Mutation: {
+      createPost: (root, { post }) => {
+        const pid = `p${Object.values(posts).length + 1}`
+
+        const result = {
+          pid,
+          ...post
+        }
+        posts[pid] = result
+
+        return result
+      },
+      updateHello: () => 'World'
+    }
+  })
+
+  const gateway = Fastify()
+  gateway.register(mercurius, {
+    graphiql: true,
+    jit: 1,
+    gateway: {
+      services: [{
+        name: 'user',
+        url: 'http://localhost:4001/graphql'
+      }, {
+        name: 'post',
+        url: 'http://localhost:4002/graphql'
+      }]
+    }
+  })
+
+  await gateway.ready()
+
+  gateway.graphql.addHook('preParsing', async function (schema, source, context) {
+    console.log('preParsing called')
+  })
+
+  gateway.graphql.addHook('preValidation', async function (schema, document, context) {
+    console.log('preValidation called')
+  })
+
+  gateway.graphql.addHook('preExecution', async function (schema, document, context) {
+    console.log('preExecution called')
+    return {
+      document,
+      errors: [
+        new Error('foo')
+      ]
+    }
+  })
+
+  gateway.graphql.addHook('preGatewayExecution', async function (schema, document, context) {
+    console.log('preGatewayExecution called')
+    return {
+      document,
+      errors: [
+        new Error('foo')
+      ]
+    }
+  })
+
+  gateway.graphql.addHook('onResolution', async function (execution, context) {
+    console.log('onResolution called')
+  })
+
+  await gateway.listen(4000)
+}
+
+start()
diff --git a/examples/hooks.js b/examples/hooks.js
new file mode 100644
index 0000000..94ea954
--- /dev/null
+++ b/examples/hooks.js
@@ -0,0 +1,63 @@
+'use strict'
+
+const Fastify = require('fastify')
+const mercurius = require('..')
+
+async function start () {
+  const app = Fastify()
+
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      add: async (_, obj) => {
+        const { x, y } = obj
+        return x + y
+      }
+    }
+  }
+
+  app.register(mercurius, {
+    schema,
+    resolvers,
+    graphiql: true
+  })
+
+  await app.ready()
+
+  app.graphql.addHook('preParsing', async function (schema, source, context) {
+    console.log('preParsing called')
+  })
+
+  app.graphql.addHook('preValidation', async function (schema, document, context) {
+    console.log('preValidation called')
+  })
+
+  app.graphql.addHook('preExecution', async function (schema, document, context) {
+    console.log('preExecution called')
+    return {
+      document,
+      errors: [
+        new Error('foo')
+      ]
+    }
+  })
+
+  app.graphql.addHook('onResolution', async function (execution, context) {
+    console.log('onResolution called')
+  })
+
+  app.get('/', async function (req, reply) {
+    const query = '{ add(x: 2, y: 2) }'
+
+    return reply.graphql(query)
+  })
+
+  app.listen(3000)
+}
+
+start()
diff --git a/index.d.ts b/index.d.ts
index bdb29d5..ae4d27f 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -61,6 +61,71 @@ export interface MercuriusLoaders<TContext extends Record<string, any> = Mercuri
   };
 }
 
+// ---------------
+// Lifecycle hooks
+// ---------------
+
+/**
+ * `preParsing` is the first hook to be executed in the GraphQL request lifecycle. The next hook will be `preValidation`.
+ */
+export interface preParsingHookHandler<TContext = MercuriusContext> {
+  (
+    schema: GraphQLSchema,
+    source: string,
+    context: TContext,
+  ): Promise<void>;
+}
+
+/**
+ * `preValidation` is the second hook to be executed in the GraphQL request lifecycle. The previous hook was `preParsing`, the next hook will be `preExecution`.
+ */
+export interface preValidationHookHandler<TContext = MercuriusContext> {
+  (
+    schema: GraphQLSchema,
+    source: DocumentNode,
+    context: TContext,
+  ): Promise<void>;
+}
+
+/**
+ * `preExecution` is the third hook to be executed in the GraphQL request lifecycle. The previous hook was `preValidation`, the next hook will be `preGatewayExecution`.
+ * Notice: in the `preExecution` hook, you can modify the following items by returning them in the hook definition:
+ *  - `document`
+ *  - `errors`
+ */
+export interface preExecutionHookHandler<TContext = MercuriusContext, TError extends Error = Error> {
+  (
+    schema: GraphQLSchema,
+    source: DocumentNode,
+    context: TContext,
+  ): Promise<PreExecutionHookResponse<TError> | void>;
+}
+
+/**
+ * `preGatewayExecution` is the fourth hook to be executed in the GraphQL request lifecycle. The previous hook was `preExecution`, the next hook will be `onResolution`.
+ * Notice: in the `preExecution` hook, you can modify the following items by returning them in the hook definition:
+ *  - `document`
+ *  - `errors`
+ * This hook will only be triggered in gateway mode. When in gateway mode, each hook definition will trigger multiple times in a single request just before executing remote GraphQL queries on the federated services.
+ */
+export interface preGatewayExecutionHookHandler<TContext = MercuriusContext, TError extends Error = Error> {
+  (
+    schema: GraphQLSchema,
+    source: DocumentNode,
+    context: TContext,
+  ): Promise<PreExecutionHookResponse<TError> | void>;
+}
+
+/**
+ * `onResolution` is the fifth and final hook to be executed in the GraphQL request lifecycle. The previous hook was `preExecution`.
+ */
+export interface onResolutionHookHandler<TData extends Record<string, any> = Record<string, any>, TContext = MercuriusContext> {
+  (
+    execution: ExecutionResult<TData>,
+    context: TContext,
+  ): Promise<void>;
+}
+
 interface ServiceConfig {
   setSchema: (schema: string) => ServiceConfig;
 }
@@ -118,6 +183,42 @@ interface MercuriusPlugin {
   schema: GraphQLSchema;
 
   gateway: Gateway;
+
+  // addHook: overloads
+
+  // Lifecycle addHooks
+
+  /**
+   * `preParsing` is the first hook to be executed in the GraphQL request lifecycle. The next hook will be `preValidation`.
+   */
+  addHook<TContext = MercuriusContext>(name: 'preParsing', hook: preParsingHookHandler<TContext>): void;
+
+  /**
+   * `preValidation` is the second hook to be executed in the GraphQL request lifecycle. The previous hook was `preParsing`, the next hook will be `preExecution`.
+   */
+  addHook<TContext = MercuriusContext>(name: 'preValidation', hook: preValidationHookHandler<TContext>): void;
+
+  /**
+   * `preExecution` is the third hook to be executed in the GraphQL request lifecycle. The previous hook was `preValidation`, the next hook will be `preGatewayExecution`.
+   * Notice: in the `preExecution` hook, you can modify the following items by returning them in the hook definition:
+   *  - `document`
+   *  - `errors`
+   */
+  addHook<TContext = MercuriusContext, TError extends Error = Error>(name: 'preExecution', hook: preExecutionHookHandler<TContext, TError>): void;
+
+  /**
+   * `preGatewayExecution` is the fourth hook to be executed in the GraphQL request lifecycle. The previous hook was `preExecution`, the next hook will be `onResolution`.
+   * Notice: in the `preExecution` hook, you can modify the following items by returning them in the hook definition:
+   *  - `document`
+   *  - `errors`
+   * This hook will only be triggered in gateway mode. When in gateway mode, each hook definition will trigger multiple times in a single request just before executing remote GraphQL queries on the federated services.
+   */
+  addHook<TContext = MercuriusContext, TError extends Error = Error>(name: 'preGatewayExecution', hook: preGatewayExecutionHookHandler<TContext, TError>): void;
+
+  /**
+   * `onResolution` is the fifth and final hook to be executed in the GraphQL request lifecycle. The previous hook was `preExecution`.
+   */
+  addHook<TData extends Record<string, any> = Record<string, any>, TContext = MercuriusContext>(name: 'onResolution', hook: onResolutionHookHandler<TData, TContext>): void;
 }
 
 interface QueryRequest {
@@ -578,3 +679,8 @@ type ValidationRules =
       variables?: Record<string, any>;
       operationName?: string;
     }) => ValidationRule[]);
+
+export interface PreExecutionHookResponse<TError extends Error> {
+  document?: DocumentNode
+  errors?: TError[]
+}
diff --git a/index.js b/index.js
index dbe377e..b88d12b 100644
--- a/index.js
+++ b/index.js
@@ -31,6 +31,7 @@ const persistedQueryDefaults = require('./lib/persistedQueryDefaults')
 const {
   ErrorWithProps,
   defaultErrorFormatter,
+  addErrorsToExecutionResult,
   MER_ERR_GQL_INVALID_SCHEMA,
   MER_ERR_GQL_GATEWAY,
   MER_ERR_GQL_VALIDATION,
@@ -38,9 +39,9 @@ const {
   MER_ERR_METHOD_NOT_ALLOWED,
   MER_ERR_INVALID_METHOD
 } = require('./lib/errors')
-
-const kLoaders = Symbol('mercurius.loaders')
-const kFactory = Symbol('mercurius.loadersFactory')
+const { Hooks, assignLifeCycleHooksToContext } = require('./lib/hooks')
+const { kLoaders, kFactory, kHooks } = require('./lib/symbols')
+const { preParsingHandler, preValidationHandler, preExecutionHandler, onResolutionHandler } = require('./lib/handlers')
 
 function buildCache (opts) {
   if (Object.prototype.hasOwnProperty.call(opts, 'cache')) {
@@ -385,10 +386,23 @@ const plugin = fp(async function (app, opts) {
     fastifyGraphQl.transformSchema(opts.schemaTransforms)
   }
 
+  fastifyGraphQl[kHooks] = new Hooks()
+
+  // Wrapper that we expose to the user for GraphQL hooks handling
+  fastifyGraphQl.addHook = function (name, fn) {
+    this[kHooks].add(name, fn)
+  }
+
   async function fastifyGraphQl (source, context, variables, operationName) {
-    context = Object.assign({ app: this, lruGatewayResolvers }, context)
+    context = Object.assign({ app: this, lruGatewayResolvers, errors: null }, context)
+    context = assignLifeCycleHooksToContext(fastifyGraphQl[kHooks], context)
     const reply = context.reply
 
+    // Trigger preParsing hook
+    if (context.preParsing !== null) {
+      await preParsingHandler({ schema: fastifyGraphQl.schema, source, context })
+    }
+
     // Parse, with a little lru
     const cached = lru !== null && lru.get(source)
     let document = null
@@ -412,6 +426,11 @@ const plugin = fp(async function (app, opts) {
         throw err
       }
 
+      // Trigger preValidation hook
+      if (context.preValidation !== null) {
+        await preValidationHandler({ schema: fastifyGraphQl.schema, document, context })
+      }
+
       // Validate
       let validationRules = []
       if (opts.validationRules) {
@@ -480,9 +499,15 @@ const plugin = fp(async function (app, opts) {
       }
     }
 
+    // Trigger preExecution hook
+    let modifiedDocument
+    if (context.preExecution !== null) {
+      ({ modifiedDocument } = await preExecutionHandler({ schema: fastifyGraphQl.schema, document, context }))
+    }
+
     const execution = await execute(
       fastifyGraphQl.schema,
-      document,
+      modifiedDocument || document,
       root,
       context,
       variables,
@@ -492,7 +517,9 @@ const plugin = fp(async function (app, opts) {
     return maybeFormatErrors(execution, context)
   }
 
-  function maybeFormatErrors (execution, context) {
+  async function maybeFormatErrors (execution, context) {
+    execution = addErrorsToExecutionResult(execution, context.errors)
+
     if (execution.errors) {
       const { reply } = context
       const { statusCode, response: { data, errors } } = errorFormatter(execution, context)
@@ -502,6 +529,11 @@ const plugin = fp(async function (app, opts) {
         reply.code(statusCode)
       }
     }
+
+    // Trigger onResolution hook
+    if (context.onResolution !== null) {
+      await onResolutionHandler({ execution, context })
+    }
     return execution
   }
 }, {
diff --git a/lib/errors.js b/lib/errors.js
index 3b93900..57f371c 100644
--- a/lib/errors.js
+++ b/lib/errors.js
@@ -93,6 +93,30 @@ function defaultErrorFormatter (err, ctx) {
   }
 }
 
+function addErrorsToExecutionResult (execution, errors) {
+  if (errors) {
+    let newErrors
+    if (execution.errors) {
+      newErrors = execution.errors.concat(errors)
+    } else {
+      newErrors = errors
+    }
+    execution.errors = newErrors
+  }
+  return execution
+}
+
+function addErrorsToContext (context, errors) {
+  let newErrors
+  if (context.errors !== null) {
+    newErrors = context.errors.concat(errors)
+  } else {
+    newErrors = errors
+  }
+
+  context.errors = newErrors
+}
+
 const errors = {
   /**
    * General errors
@@ -176,6 +200,26 @@ const errors = {
   MER_ERR_GQL_SUBSCRIPTION_MESSAGE_INVALID: createError(
     'MER_ERR_GQL_SUBSCRIPTION_MESSAGE_INVALID',
     'Invalid message received: %s'
+  ),
+  /**
+   * Hooks errors
+   */
+  MER_ERR_HOOK_INVALID_TYPE: createError(
+    'MER_ERR_HOOK_INVALID_TYPE',
+    'The hook name must be a string',
+    500,
+    TypeError
+  ),
+  MER_ERR_HOOK_INVALID_HANDLER: createError(
+    'MER_ERR_HOOK_INVALID_HANDLER',
+    'The hook callback must be a function',
+    500,
+    TypeError
+  ),
+  MER_ERR_HOOK_UNSUPPORTED_HOOK: createError(
+    'MER_ERR_HOOK_UNSUPPORTED_HOOK',
+    '%s hook not supported!',
+    500
   )
 }
 
@@ -183,3 +227,5 @@ module.exports = errors
 module.exports.ErrorWithProps = ErrorWithProps
 module.exports.FederatedError = FederatedError
 module.exports.defaultErrorFormatter = defaultErrorFormatter
+module.exports.addErrorsToExecutionResult = addErrorsToExecutionResult
+module.exports.addErrorsToContext = addErrorsToContext
diff --git a/lib/gateway.js b/lib/gateway.js
index 0ff569f..8d34c53 100644
--- a/lib/gateway.js
+++ b/lib/gateway.js
@@ -4,7 +4,8 @@ const {
   getNamedType,
   isObjectType,
   isScalarType,
-  Kind
+  Kind,
+  parse
 } = require('graphql')
 const { Factory } = require('single-user-cache')
 const buildFederatedSchema = require('./federation')
@@ -17,6 +18,7 @@ const {
   kEntityResolvers
 } = require('./gateway/make-resolver')
 const { MER_ERR_GQL_GATEWAY_REFRESH } = require('./errors')
+const { preGatewayExecutionHandler } = require('./handlers')
 
 const allSettled = require('promise.allsettled')
 
@@ -245,7 +247,7 @@ async function buildGateway (gatewayOpts, app) {
      * However returning the correct response for the two orignal query is not trival hence it remains a future todo
      *
      */
-    factory.add(`${service}Entity`, async (queries, context) => {
+    factory.add(`${service}Entity`, async (queries) => {
       const q = [...new Set(queries.map(q => q.query))]
 
       const resultIndexes = []
@@ -272,11 +274,22 @@ async function buildGateway (gatewayOpts, app) {
 
       const result = []
 
+      // Gateway query here
       await Promise.all(Object.entries(mergedQueries).map(async ([query, variables], queryIndex, entries) => {
+        // Trigger preGatewayExecution hook for entities
+        let modifiedQuery
+        if (queries[queryIndex].context.preGatewayExecution !== null) {
+          ({ modifiedQuery } = await preGatewayExecutionHandler({
+            schema: serviceDefinition.schema,
+            document: parse(query),
+            context: queries[queryIndex].context
+          }))
+        }
+
         const response = await serviceDefinition.sendRequest({
           originalRequestHeaders: queries[queryIndex].originalRequestHeaders,
           body: JSON.stringify({
-            query,
+            query: modifiedQuery || query,
             variables
           })
         })
diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js
index c0f19a2..8afea4e 100644
--- a/lib/gateway/make-resolver.js
+++ b/lib/gateway/make-resolver.js
@@ -6,6 +6,7 @@ const {
   parse,
   Kind
 } = require('graphql')
+const { preGatewayExecutionHandler } = require('../handlers')
 
 const kEntityResolvers = Symbol('mercurius.entity-resolvers')
 
@@ -344,7 +345,7 @@ function generatePathKey (path) {
  *
  */
 function makeResolver ({ service, createOperation, transformData, isQuery, isReference, isSubscription }) {
-  return function (parent, args, context, info) {
+  return async function (parent, args, context, info) {
     const {
       fieldNodes,
       returnType,
@@ -363,6 +364,7 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef
 
     const cached = lruGatewayResolvers.get(`${__currentQuery}_${resolverKey}`)
     let variableNamesToDefine
+    let operation
     let query
 
     // Get the actual type as the returnType can be NonNull or List as well
@@ -371,6 +373,7 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef
     if (cached) {
       variableNamesToDefine = cached.variableNamesToDefine
       query = cached.query
+      operation = cached.operation
     } else {
       // Remove items from selections that are not defined in the service
       const selections = fieldNodes[0].selectionSet ? removeNonServiceTypeFields(fieldNodes[0].selectionSet.selections, service, type, schema) : []
@@ -381,7 +384,7 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef
       const variablesToDefine = originalOperation.variableDefinitions.filter(definition => variableNamesToDefine.has(definition.variable.name.value))
 
       // create the operation that will be sent to the service
-      const operation = createOperation({
+      operation = createOperation({
         returnType: type,
         parentType,
         fieldName,
@@ -410,7 +413,7 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef
         }
       }
 
-      lruGatewayResolvers.set(`${__currentQuery}_${resolverKey}`, { query, variableNamesToDefine })
+      lruGatewayResolvers.set(`${__currentQuery}_${resolverKey}`, { query, operation, variableNamesToDefine })
     }
 
     const variables = {}
@@ -441,21 +444,32 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef
     }
 
     if (isQuery) {
-      return service.sendRequest({
+      // Trigger preGatewayExecution hook
+      let modifiedQuery
+      if (context.preGatewayExecution !== null) {
+        ({ modifiedQuery } = await preGatewayExecutionHandler({ schema, document: operation, context }))
+      }
+
+      const response = await service.sendRequest({
         method: 'POST',
         body: JSON.stringify({
-          query,
+          query: modifiedQuery || query,
           variables
         }),
         originalRequestHeaders: reply.request.headers
-      }).then(transformData)
+      })
+
+      return transformData(response)
     }
 
-    return reply[kEntityResolvers][`${service.name}Entity`]({
+    const response = await reply[kEntityResolvers][`${service.name}Entity`]({
       query,
       variables,
-      originalRequestHeaders: reply.request.headers
-    }).then(transformData)
+      originalRequestHeaders: reply.request.headers,
+      context
+    })
+
+    return transformData(response)
   }
 }
 
diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js
index d3ec4b0..ae7f2ab 100644
--- a/lib/gateway/service-map.js
+++ b/lib/gateway/service-map.js
@@ -4,12 +4,15 @@ const URL = require('url').URL
 const {
   isTypeExtensionNode,
   isTypeDefinitionNode,
-  parse
+  parse,
+  buildSchema,
+  GraphQLSchema
 } = require('graphql')
 
 const { buildRequest, sendRequest } = require('./request')
 const SubscriptionClient = require('../subscription-client')
 const { MER_ERR_GQL_GATEWAY_INIT } = require('../errors')
+const buildFederationSchema = require('../federation')
 
 function hasDirective (directiveName, node) {
   const { directives = [] } = node
@@ -101,6 +104,14 @@ async function getRemoteSchemaDefinition (serviceConfig, initHeaders) {
   return data._service.sdl
 }
 
+function safeBuildSchema (schemaDefinition) {
+  try {
+    return buildSchema(schemaDefinition)
+  } catch {
+    return buildFederationSchema(schemaDefinition)
+  }
+}
+
 async function buildServiceMap (services, errorHandler) {
   const serviceMap = {}
 
@@ -119,8 +130,9 @@ async function buildServiceMap (services, errorHandler) {
           return serviceConfig
         }
 
-        const { schemaDefinition, typeMap, types, extensionTypeMap } = await serviceConfig.init()
+        const { schema, schemaDefinition, typeMap, types, extensionTypeMap } = await serviceConfig.init()
 
+        serviceConfig.schema = schema
         serviceConfig.schemaDefinition = schemaDefinition
         serviceConfig.typeMap = typeMap
         serviceConfig.types = types
@@ -145,22 +157,26 @@ async function buildServiceMap (services, errorHandler) {
         const schemaDefinition = opts.schema || await getRemoteSchemaDefinition(serviceConfig, initHeaders)
 
         const { typeMap, types, extensionTypeMap } = createTypeMap(schemaDefinition)
+        const schema = safeBuildSchema(schemaDefinition)
 
         return {
+          schema,
           schemaDefinition,
           typeMap,
           types,
           extensionTypeMap
         }
       },
-      setSchema (schema) {
-        if (serviceConfig.schemaDefinition === schema) {
+      setSchema (schemaDefinition) {
+        if (serviceConfig.schemaDefinition === schemaDefinition) {
           return serviceConfig
         }
 
-        const { typeMap, types, extensionTypeMap } = createTypeMap(schema)
+        const { typeMap, types, extensionTypeMap } = createTypeMap(schemaDefinition)
+        const schema = safeBuildSchema(schemaDefinition)
 
-        serviceConfig.schemaDefinition = schema
+        serviceConfig.schema = schema
+        serviceConfig.schemaDefinition = schemaDefinition
         serviceConfig.typeMap = typeMap
         serviceConfig.types = types
         serviceConfig.extensionTypeMap = extensionTypeMap
@@ -187,11 +203,13 @@ async function buildServiceMap (services, errorHandler) {
       })
 
     if (serviceConfig) {
+      serviceMap[service.name].schema = serviceConfig.schema
       serviceMap[service.name].schemaDefinition = serviceConfig.schemaDefinition
       serviceMap[service.name].typeMap = serviceConfig.typeMap
       serviceMap[service.name].types = serviceConfig.types
       serviceMap[service.name].extensionTypeMap = serviceConfig.extensionTypeMap
     } else {
+      serviceMap[service.name].schema = new GraphQLSchema({})
       serviceMap[service.name].schemaDefinition = ''
       serviceMap[service.name].typeMap = {}
       serviceMap[service.name].types = new Set()
diff --git a/lib/handlers.js b/lib/handlers.js
new file mode 100644
index 0000000..5f4667f
--- /dev/null
+++ b/lib/handlers.js
@@ -0,0 +1,59 @@
+'use strict'
+
+const { hooksRunner, preExecutionHooksRunner, hookRunner, preParsingHookRunner, onResolutionHookRunner } = require('./hooks')
+const { addErrorsToContext } = require('./errors')
+const { print } = require('graphql')
+
+async function preParsingHandler (request) {
+  await hooksRunner(
+    request.context.preParsing,
+    preParsingHookRunner,
+    request
+  )
+}
+
+async function preValidationHandler (request) {
+  await hooksRunner(
+    request.context.preValidation,
+    hookRunner,
+    request
+  )
+}
+
+async function preExecutionHandler (request) {
+  const { errors, modifiedDocument } = await preExecutionHooksRunner(
+    request.context.preExecution,
+    request
+  )
+  if (errors.length > 0) {
+    addErrorsToContext(request.context, errors)
+  }
+  if (typeof modifiedDocument !== 'undefined') {
+    return { modifiedDocument, modifiedQuery: print(request.document) }
+  }
+  return {}
+}
+
+async function preGatewayExecutionHandler (request) {
+  const { errors, modifiedDocument } = await preExecutionHooksRunner(
+    request.context.preGatewayExecution,
+    request
+  )
+  if (errors.length > 0) {
+    addErrorsToContext(request.context, errors)
+  }
+  if (typeof modifiedDocument !== 'undefined') {
+    return { modifiedDocument, modifiedQuery: print(modifiedDocument) }
+  }
+  return {}
+}
+
+async function onResolutionHandler (request) {
+  await hooksRunner(
+    request.context.onResolution,
+    onResolutionHookRunner,
+    request
+  )
+}
+
+module.exports = { preParsingHandler, preValidationHandler, preExecutionHandler, preGatewayExecutionHandler, onResolutionHandler }
diff --git a/lib/hooks.js b/lib/hooks.js
new file mode 100644
index 0000000..fc1c151
--- /dev/null
+++ b/lib/hooks.js
@@ -0,0 +1,90 @@
+'use strict'
+
+const lifecycleHooks = ['preParsing', 'preValidation', 'preExecution', 'preGatewayExecution', 'onResolution']
+const { MER_ERR_HOOK_INVALID_TYPE, MER_ERR_HOOK_INVALID_HANDLER, MER_ERR_HOOK_UNSUPPORTED_HOOK } = require('./errors')
+
+function Hooks () {
+  this.preParsing = []
+  this.preValidation = []
+  this.preExecution = []
+  this.preGatewayExecution = []
+  this.onResolution = []
+}
+
+Hooks.prototype.validate = function (hook, fn) {
+  if (typeof hook !== 'string') throw new MER_ERR_HOOK_INVALID_TYPE()
+  if (typeof fn !== 'function') throw new MER_ERR_HOOK_INVALID_HANDLER()
+  if (lifecycleHooks.indexOf(hook) === -1) {
+    throw new MER_ERR_HOOK_UNSUPPORTED_HOOK(hook)
+  }
+}
+
+Hooks.prototype.add = function (hook, fn) {
+  this.validate(hook, fn)
+  this[hook].push(fn)
+}
+
+function assignLifeCycleHooksToContext (hooks, context) {
+  const contextHooks = {
+    preParsing: null,
+    preValidation: null,
+    preExecution: null,
+    preGatewayExecution: null,
+    onResolution: null
+  }
+  if (hooks.preParsing.length > 0) contextHooks.preParsing = hooks.preParsing.slice()
+  if (hooks.preValidation.length > 0) contextHooks.preValidation = hooks.preValidation.slice()
+  if (hooks.preExecution.length > 0) contextHooks.preExecution = hooks.preExecution.slice()
+  if (hooks.preGatewayExecution.length > 0) contextHooks.preGatewayExecution = hooks.preGatewayExecution.slice()
+  if (hooks.onResolution.length > 0) contextHooks.onResolution = hooks.onResolution.slice()
+  return Object.assign(contextHooks, context)
+}
+
+async function hooksRunner (functions, runner, request) {
+  for (const fn of functions) {
+    await runner(fn, request)
+  }
+}
+
+async function preExecutionHooksRunner (functions, request) {
+  let errors = []
+  let modifiedDocument
+
+  for (const fn of functions) {
+    const result = await fn(request.schema, modifiedDocument || request.document, request.context)
+
+    if (result) {
+      if (typeof result.document !== 'undefined') {
+        modifiedDocument = result.document
+      }
+      if (typeof result.errors !== 'undefined') {
+        errors = errors.concat(result.errors)
+      }
+    }
+  }
+
+  return { errors, modifiedDocument }
+}
+
+function hookRunner (fn, request) {
+  return fn(request.schema, request.document, request.context)
+}
+
+function preParsingHookRunner (fn, request) {
+  return fn(request.schema, request.source, request.context)
+}
+
+function onResolutionHookRunner (fn, request) {
+  return fn(request.execution, request.context)
+}
+
+module.exports = {
+  Hooks,
+  assignLifeCycleHooksToContext,
+  hooksRunner,
+  preExecutionHooksRunner,
+  hookRunner,
+  preParsingHookRunner,
+  onResolutionHookRunner,
+  lifecycleHooks
+}
diff --git a/lib/subscription.js b/lib/subscription.js
index 1a7e459..8e37835 100644
--- a/lib/subscription.js
+++ b/lib/subscription.js
@@ -1,6 +1,8 @@
 'use strict'
 
 const fastifyWebsocket = require('fastify-websocket')
+const { assignLifeCycleHooksToContext, Hooks } = require('./hooks')
+const { kHooks } = require('./symbols')
 const SubscriptionConnection = require('./subscription-connection')
 const GRAPHQL_WS = 'graphql-ws'
 
@@ -21,9 +23,16 @@ function createConnectionHandler ({ subscriber, fastify, onConnect, onDisconnect
       return
     }
 
-    const context = {
+    let context = {
       app: fastify
     }
+
+    if (context.app.graphql && context.app.graphql[kHooks]) {
+      context = assignLifeCycleHooksToContext(context.app.graphql[kHooks], context)
+    } else {
+      context = assignLifeCycleHooksToContext(new Hooks(), context)
+    }
+
     let resolveContext
 
     if (subscriptionContextFn) {
diff --git a/lib/symbols.js b/lib/symbols.js
new file mode 100644
index 0000000..da9462b
--- /dev/null
+++ b/lib/symbols.js
@@ -0,0 +1,9 @@
+'use strict'
+
+const keys = {
+  kLoaders: Symbol('mercurius.loaders'),
+  kFactory: Symbol('mercurius.loadersFactory'),
+  kHooks: Symbol('mercurius.hooks')
+}
+
+module.exports = keys
diff --git a/package.json b/package.json
index cf0635d..fd92c73 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
   "types": "index.d.ts",
   "scripts": {
     "docs": "docsify serve",
-    "unit": "tap --100 test/*.js test/gateway/*.js",
+    "unit": "tap --100 test/*.js test/gateway/*.js test/internals/*.js",
     "cov": "tap  --coverage-report=html -J test/*.js test/gateway/*.js",
     "lint": "npm run lint:standard && npm run lint:typescript",
     "lint:standard": "standard | snazzy",


Test patch:
diff --git a/test/gateway/hooks.js b/test/gateway/hooks.js
new file mode 100644
index 0000000..0e7fb0b
--- /dev/null
+++ b/test/gateway/hooks.js
@@ -0,0 +1,1044 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const { GraphQLSchema, parse } = require('graphql')
+const { promisify } = require('util')
+const GQL = require('../..')
+
+const immediate = promisify(setImmediate)
+
+async function createTestService (t, schema, resolvers = {}) {
+  const service = Fastify()
+  t.tearDown(() => {
+    service.close()
+  })
+  service.register(GQL, {
+    schema,
+    resolvers,
+    federationMetadata: true
+  })
+  await service.listen(0)
+
+  return service.server.address().port
+}
+
+const users = {
+  u1: {
+    id: 'u1',
+    name: 'John'
+  },
+  u2: {
+    id: 'u2',
+    name: 'Jane'
+  }
+}
+
+const posts = {
+  p1: {
+    pid: 'p1',
+    title: 'Post 1',
+    content: 'Content 1',
+    authorId: 'u1'
+  },
+  p2: {
+    pid: 'p2',
+    title: 'Post 2',
+    content: 'Content 2',
+    authorId: 'u2'
+  },
+  p3: {
+    pid: 'p3',
+    title: 'Post 3',
+    content: 'Content 3',
+    authorId: 'u1'
+  },
+  p4: {
+    pid: 'p4',
+    title: 'Post 4',
+    content: 'Content 4',
+    authorId: 'u1'
+  }
+}
+
+const query = `
+  query {
+    me {
+      id
+      name
+      topPosts(count: 2) {
+        pid
+        author {
+          id
+        }
+      }
+    }
+    topPosts(count: 2) {
+      pid
+    }
+  }
+`
+
+async function createTestGatewayServer (t) {
+  // User service
+  const userServiceSchema = `
+  type Query @extends {
+    me: User
+  }
+
+  type User @key(fields: "id") {
+    id: ID!
+    name: String!
+  }`
+  const userServiceResolvers = {
+    Query: {
+      me: (root, args, context, info) => {
+        return users.u1
+      }
+    },
+    User: {
+      __resolveReference: (user, args, context, info) => {
+        return users[user.id]
+      }
+    }
+  }
+  const userServicePort = await createTestService(t, userServiceSchema, userServiceResolvers)
+
+  // Post service
+  const postServiceSchema = `
+  type Post @key(fields: "pid") {
+    pid: ID!
+    author: User
+  }
+
+  extend type Query {
+    topPosts(count: Int): [Post]
+  }
+
+  type User @key(fields: "id") @extends {
+    id: ID! @external
+    topPosts(count: Int!): [Post]
+  }`
+  const postServiceResolvers = {
+    Post: {
+      __resolveReference: (post, args, context, info) => {
+        return posts[post.pid]
+      },
+      author: (post, args, context, info) => {
+        return {
+          __typename: 'User',
+          id: post.authorId
+        }
+      }
+    },
+    User: {
+      topPosts: (user, { count }, context, info) => {
+        return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count)
+      }
+    },
+    Query: {
+      topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count)
+    }
+  }
+  const postServicePort = await createTestService(t, postServiceSchema, postServiceResolvers)
+
+  const gateway = Fastify()
+  t.tearDown(() => {
+    gateway.close()
+  })
+  gateway.register(GQL, {
+    gateway: {
+      services: [{
+        name: 'user',
+        url: `http://localhost:${userServicePort}/graphql`
+      }, {
+        name: 'post',
+        url: `http://localhost:${postServicePort}/graphql`
+      }]
+    }
+  })
+  return gateway
+}
+
+// -----
+// hooks
+// -----
+test('gateway - hooks', async (t) => {
+  t.plan(32)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preParsing', async function (schema, source, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.ok('preParsing called')
+  })
+
+  app.graphql.addHook('preValidation', async function (schema, document, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preValidation called')
+  })
+
+  app.graphql.addHook('preExecution', async function (schema, document, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preExecution called')
+  })
+
+  // Execution events:
+  //  - once for user service query
+  //  - once for post service query
+  //  - once for reference type topPosts on User
+  //  - once for reference type author on Post
+  app.graphql.addHook('preGatewayExecution', async function (schema, document, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.true(document, 'object')
+    t.type(context, 'object')
+    t.ok('preGatewayExecution called')
+  })
+
+  app.graphql.addHook('onResolution', async function (execution, context) {
+    await immediate()
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    t.ok('onResolution called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+test('gateway - hooks validation should handle invalid hook names', async (t) => {
+  t.plan(1)
+  const app = await createTestGatewayServer(t)
+
+  try {
+    app.graphql.addHook('unsupportedHook', async () => {})
+  } catch (e) {
+    t.strictEqual(e.message, 'unsupportedHook hook not supported!')
+  }
+})
+
+test('gateway - hooks validation should handle invalid hook name types', async (t) => {
+  t.plan(2)
+  const app = await createTestGatewayServer(t)
+
+  try {
+    app.graphql.addHook(1, async () => {})
+  } catch (e) {
+    t.strictEqual(e.code, 'MER_ERR_HOOK_INVALID_TYPE')
+    t.strictEqual(e.message, 'The hook name must be a string')
+  }
+})
+
+test('gateway - hooks validation should handle invalid hook handlers', async (t) => {
+  t.plan(2)
+  const app = await createTestGatewayServer(t)
+
+  try {
+    app.graphql.addHook('preParsing', 'not a function')
+  } catch (e) {
+    t.strictEqual(e.code, 'MER_ERR_HOOK_INVALID_HANDLER')
+    t.strictEqual(e.message, 'The hook callback must be a function')
+  }
+})
+
+// --------------------
+// preParsing
+// --------------------
+test('gateway - preParsing hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    throw new Error('a preParsing error occured')
+  })
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, operation, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preParsing error occured'
+      }
+    ]
+  })
+})
+
+test('gateway - preParsing hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+// --------------
+// preValidation
+// --------------
+test('gateway - preValidation hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    throw new Error('a preValidation error occured')
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preValidation error occured'
+      }
+    ]
+  })
+})
+
+test('gateway - preValidation hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+// -------------
+// preExecution
+// -------------
+test('gateway - preExecution hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    throw new Error('a preExecution error occured')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preExecution error occured'
+      }
+    ]
+  })
+})
+
+test('gateway - preExecution hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+test('gateway - preExecution hooks should be able to modify the request document', async t => {
+  t.plan(5)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preExecution called')
+    const documentClone = JSON.parse(JSON.stringify(document))
+    documentClone.definitions[0].selectionSet.selections = [documentClone.definitions[0].selectionSet.selections[0]]
+    return {
+      document: documentClone
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      }
+    }
+  })
+})
+
+test('gateway - preExecution hooks should be able to add to the errors array', async t => {
+  t.plan(9)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preExecution called for foo error')
+    return {
+      errors: [new Error('foo')]
+    }
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preExecution called for foo error')
+    return {
+      errors: [new Error('bar')]
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    },
+    errors: [
+      {
+        message: 'foo'
+      },
+      {
+        message: 'bar'
+      }
+    ]
+  })
+})
+
+// -------------------
+// preGatewayExecution
+// -------------------
+test('gateway - preGatewayExecution hooks should handle errors', async t => {
+  t.plan(10)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    throw new Error('a preGatewayExecution error occured')
+  })
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  // This should still be called in the gateway
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    t.ok('onResolution called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: null,
+      topPosts: null
+    },
+    errors: [
+      {
+        message: 'a preGatewayExecution error occured',
+        locations: [{ line: 3, column: 5 }],
+        path: ['me']
+      },
+      {
+        message: 'a preGatewayExecution error occured',
+        locations: [{ line: 13, column: 5 }],
+        path: ['topPosts']
+      }
+    ]
+  })
+})
+
+test('gateway - preGatewayExecution hooks should be able to put values onto the context', async t => {
+  t.plan(29)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    context[document.definitions[0].name.value] = 'bar'
+  })
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    t.is(context[document.definitions[0].name.value], 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+test('gateway - preGatewayExecution hooks should be able to add to the errors array', async t => {
+  t.plan(33)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    t.ok('preGatewayExecution called for foo error')
+    return {
+      errors: [new Error(`foo - ${document.definitions[0].name.value}`)]
+    }
+  })
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    t.ok('preGatewayExecution called for foo error')
+    return {
+      errors: [new Error(`bar - ${document.definitions[0].name.value}`)]
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    },
+    errors: [
+      {
+        message: 'foo - Query_me'
+      },
+      {
+        message: 'bar - Query_me'
+      },
+      {
+        message: 'foo - Query_topPosts'
+      },
+      {
+        message: 'bar - Query_topPosts'
+      },
+      {
+        message: 'foo - EntitiesQuery'
+      },
+      {
+        message: 'bar - EntitiesQuery'
+      },
+      {
+        message: 'foo - EntitiesQuery'
+      },
+      {
+        message: 'bar - EntitiesQuery'
+      }
+    ]
+  })
+})
+
+test('gateway - preGatewayExecution hooks should be able to modify the request document', async t => {
+  t.plan(17)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.type(document, 'object')
+    t.type(context, 'object')
+    t.ok('preGatewayExecution called')
+    if (document.definitions[0].name.value === 'EntitiesQuery') {
+      if (document.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0]) {
+        const documentClone = JSON.parse(JSON.stringify(document))
+        documentClone.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0].value.value = 1
+        return {
+          document: documentClone
+        }
+      }
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
+
+// -------------
+// onResolution
+// -------------
+test('gateway - onResolution hooks should handle errors', async t => {
+  t.plan(3)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    throw new Error('a onResolution error occured')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a onResolution error occured'
+      }
+    ]
+  })
+})
+
+test('gateway - onResolution hooks should be able to put values onto the context', async t => {
+  t.plan(6)
+  const app = await createTestGatewayServer(t)
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      me: {
+        id: 'u1',
+        name: 'John',
+        topPosts: [
+          {
+            pid: 'p1',
+            author: {
+              id: 'u1'
+            }
+          },
+          {
+            pid: 'p3',
+            author: {
+              id: 'u1'
+            }
+          }
+        ]
+      },
+      topPosts: [
+        {
+          pid: 'p1'
+        },
+        {
+          pid: 'p2'
+        }
+      ]
+    }
+  })
+})
diff --git a/test/gateway/pollingInterval.js b/test/gateway/pollingInterval.js
index 0d3053c..df004f7 100644
--- a/test/gateway/pollingInterval.js
+++ b/test/gateway/pollingInterval.js
@@ -1012,7 +1012,6 @@ test('Polling schemas (should properly regenerate the schema when a downstream s
 
   const userService = Fastify()
   const gateway = Fastify()
-  const userServicePort = 3001
 
   userService.register(GQL, {
     schema: oldSchema,
@@ -1027,7 +1026,9 @@ test('Polling schemas (should properly regenerate the schema when a downstream s
     federationMetadata: true
   })
 
-  await userService.listen(userServicePort)
+  await userService.listen(0)
+
+  const userServicePort = userService.server.address().port
 
   gateway.register(GQL, {
     gateway: {
@@ -1070,7 +1071,7 @@ test('Polling schemas (should properly regenerate the schema when a downstream s
     }
   })
 
-  userService.close()
+  await userService.close()
 
   const restartedUserService = Fastify()
 
diff --git a/test/hooks.js b/test/hooks.js
new file mode 100644
index 0000000..ead9926
--- /dev/null
+++ b/test/hooks.js
@@ -0,0 +1,649 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const { parse, GraphQLSchema } = require('graphql')
+const { promisify } = require('util')
+const GQL = require('..')
+
+const immediate = promisify(setImmediate)
+
+process.removeAllListeners('warning')
+
+const schema = `
+  type Query {
+    add(x: Int, y: Int): Int
+  }
+`
+
+const resolvers = {
+  Query: {
+    add: async (_, obj) => {
+      const { x, y } = obj
+      return x + y
+    }
+  }
+}
+
+const query = '{ add(x: 2, y: 2) }'
+
+function createTestServer (t, customResolvers = resolvers) {
+  const app = Fastify()
+  t.tearDown(() => app.close())
+
+  app.register(GQL, { schema, resolvers: customResolvers })
+
+  return app
+}
+
+// -----
+// hooks
+// -----
+test('hooks', async t => {
+  t.plan(16)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preParsing', async function (schema, source, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.ok('preParsing called')
+  })
+
+  app.graphql.addHook('preValidation', async function (schema, document, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preValidation called')
+  })
+
+  app.graphql.addHook('preExecution', async function (schema, document, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.ok('preExecution called')
+  })
+
+  app.graphql.addHook('preGatewayExecution', async function (schema, document, context) {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async function (execution, context) {
+    await immediate()
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    t.ok('onResolution called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+test('hooks can be called multiple times', async t => {
+  t.plan(9)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preParsing', async function (schema, source, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.ok('preParsing called')
+  })
+
+  app.graphql.addHook('preParsing', async function (schema, source, context) {
+    await immediate()
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.ok('preParsing called again')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+test('hooks validation should handle invalid hook names', async t => {
+  t.plan(1)
+  const app = await createTestServer(t)
+
+  try {
+    app.graphql.addHook('unsupportedHook', async () => {})
+  } catch (e) {
+    t.strictEqual(e.message, 'unsupportedHook hook not supported!')
+  }
+})
+
+test('hooks validation should handle invalid hook name types', async t => {
+  t.plan(2)
+  const app = await createTestServer(t)
+
+  try {
+    app.graphql.addHook(1, async () => {})
+  } catch (e) {
+    t.strictEqual(e.code, 'MER_ERR_HOOK_INVALID_TYPE')
+    t.strictEqual(e.message, 'The hook name must be a string')
+  }
+})
+
+test('hooks validation should handle invalid hook handlers', async t => {
+  t.plan(2)
+  const app = await createTestServer(t)
+
+  try {
+    app.graphql.addHook('preParsing', 'not a function')
+  } catch (e) {
+    t.strictEqual(e.code, 'MER_ERR_HOOK_INVALID_HANDLER')
+    t.strictEqual(e.message, 'The hook callback must be a function')
+  }
+})
+
+// -----------
+// preParsing
+// -----------
+test('preParsing hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    throw new Error('a preParsing error occured')
+  })
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preParsing error occured'
+      }
+    ]
+  })
+})
+
+test('preParsing hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preParsing', async (schema, source, context) => {
+    t.type(schema, GraphQLSchema)
+    t.is(source, query)
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+// --------------
+// preValidation
+// --------------
+test('preValidation hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    throw new Error('a preValidation error occured')
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preValidation error occured'
+      }
+    ]
+  })
+})
+
+test('preValidation hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preValidation', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+// -------------
+// preExecution
+// -------------
+test('preExecution hooks should handle errors', async t => {
+  t.plan(4)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    throw new Error('a preExecution error occured')
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a preExecution error occured'
+      }
+    ]
+  })
+})
+
+test('preExecution hooks should be able to put values onto the context', async t => {
+  t.plan(8)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+test('preExecution hooks should be able to modify the query document AST', async t => {
+  t.plan(4)
+  const app = await createTestServer(t)
+
+  const query = '{ add(x: 2, y: 2) }'
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    const modifiedDocument = {
+      kind: 'Document',
+      definitions: [
+        {
+          kind: 'OperationDefinition',
+          operation: 'query',
+          variableDefinitions: [],
+          directives: [],
+          selectionSet: {
+            kind: 'SelectionSet',
+            selections: [
+              {
+                kind: 'Field',
+                name: { kind: 'Name', value: 'add', loc: { start: 2, end: 5 } },
+                arguments: [
+                  {
+                    kind: 'Argument',
+                    name: { kind: 'Name', value: 'x', loc: { start: 6, end: 7 } },
+                    value: { kind: 'IntValue', value: '5', loc: { start: 9, end: 10 } },
+                    loc: { start: 6, end: 10 }
+                  },
+                  {
+                    kind: 'Argument',
+                    name: { kind: 'Name', value: 'y', loc: { start: 12, end: 13 } },
+                    value: { kind: 'IntValue', value: '5', loc: { start: 15, end: 16 } },
+                    loc: { start: 12, end: 16 }
+                  }
+                ],
+                directives: [],
+                loc: { start: 2, end: 17 }
+              }
+            ],
+            loc: { start: 0, end: 19 }
+          },
+          loc: { start: 0, end: 19 }
+        }
+      ],
+      loc: { start: 0, end: 19 }
+    }
+    return {
+      document: modifiedDocument
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 10
+    }
+  })
+})
+
+test('preExecution hooks should be able to add to the errors array', async t => {
+  t.plan(7)
+  const app = await createTestServer(t)
+
+  const query = '{ add(x: 2, y: 2) }'
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    return {
+      errors: [new Error('foo')]
+    }
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    return {
+      errors: [new Error('bar')]
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    },
+    errors: [
+      {
+        message: 'foo'
+      },
+      {
+        message: 'bar'
+      }
+    ]
+  })
+})
+
+test('preExecution hooks should be able to add to the errors array that is already populated with execution errors', async t => {
+  t.plan(7)
+  const resolvers = {
+    Query: {
+      add: async (_, obj) => {
+        throw new Error('resolver error')
+      }
+    }
+  }
+
+  const app = await createTestServer(t, resolvers)
+
+  const query = '{ add(x: 2, y: 2) }'
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    return {
+      errors: [new Error('foo')]
+    }
+  })
+
+  app.graphql.addHook('preExecution', async (schema, document, context) => {
+    t.type(schema, GraphQLSchema)
+    t.deepEqual(document, parse(query))
+    t.type(context, 'object')
+    return {
+      errors: [new Error('bar')]
+    }
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: null
+    },
+    errors: [
+      {
+        message: 'resolver error',
+        locations: [{ line: 1, column: 3 }],
+        path: ['add']
+      },
+      {
+        message: 'foo'
+      },
+      {
+        message: 'bar'
+      }
+    ]
+  })
+})
+
+// -------------
+// preGatewayExecution
+// -------------
+test('preGatewayExecution hooks should never be called in a non-gateway graphql server', async t => {
+  t.plan(1)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('preGatewayExecution', async (schema, document, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
+
+// -------------
+// onResolution
+// -------------
+test('onResolution hooks should handle errors', async t => {
+  t.plan(3)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    throw new Error('a onResolution error occured')
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.fail('this should not be called')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: null,
+    errors: [
+      {
+        message: 'a onResolution error occured'
+      }
+    ]
+  })
+})
+
+test('onResolution hooks should be able to put values onto the context', async t => {
+  t.plan(6)
+  const app = await createTestServer(t)
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    context.foo = 'bar'
+  })
+
+  app.graphql.addHook('onResolution', async (execution, context) => {
+    t.type(execution, 'object')
+    t.type(context, 'object')
+    t.is(context.foo, 'bar')
+  })
+
+  const res = await app.inject({
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    url: '/graphql',
+    body: JSON.stringify({ query })
+  })
+
+  t.deepEqual(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
diff --git a/test/internals/hooksRunner.js b/test/internals/hooksRunner.js
new file mode 100644
index 0000000..7397f0b
--- /dev/null
+++ b/test/internals/hooksRunner.js
@@ -0,0 +1,319 @@
+'use strict'
+
+const { test } = require('tap')
+const { hooksRunner, preExecutionHooksRunner } = require('../../lib/hooks')
+
+test('hooksRunner - Basic', (t) => {
+  t.plan(3)
+
+  hooksRunner([fn1, fn2, fn3], iterator, 'a')
+
+  function iterator (fn, a) {
+    return fn(a)
+  }
+
+  function fn1 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.resolve()
+  }
+
+  function fn2 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.resolve()
+  }
+
+  function fn3 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.resolve()
+  }
+})
+
+test('hooksRunner - In case of error should skip subsequent functions', async (t) => {
+  t.plan(3)
+
+  try {
+    await hooksRunner([fn1, fn2, fn3], iterator, 'a')
+  } catch (err) {
+    t.strictEqual(err.message, 'kaboom')
+  }
+
+  function iterator (fn, a) {
+    return fn(a)
+  }
+
+  function fn1 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.resolve()
+  }
+
+  function fn2 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.reject(new Error('kaboom'))
+  }
+
+  function fn3 () {
+    t.fail('We should not be here')
+  }
+})
+
+test('hooksRunner - Be able to exit before its natural end', async (t) => {
+  t.plan(2)
+
+  let shouldStop = false
+
+  await hooksRunner([fn1, fn2, fn3], iterator, 'a')
+
+  function iterator (fn, a) {
+    if (shouldStop) {
+      return undefined
+    }
+    return fn(a)
+  }
+
+  function fn1 (a) {
+    t.strictEqual(a, 'a')
+    return Promise.resolve()
+  }
+
+  function fn2 (a) {
+    t.strictEqual(a, 'a')
+    shouldStop = true
+    return Promise.resolve()
+  }
+
+  function fn3 () {
+    t.fail('this should not be called')
+  }
+})
+
+test('hooksRunner - Promises that resolve to a value do not change the state', (t) => {
+  t.plan(4)
+
+  const originalState = { a: 'a' }
+
+  hooksRunner([fn1, fn2, fn3, fn4], iterator, originalState)
+
+  function iterator (fn, state) {
+    return fn(state)
+  }
+
+  function fn1 (state) {
+    t.strictEqual(state, originalState)
+    return Promise.resolve(null)
+  }
+
+  function fn2 (state) {
+    t.strictEqual(state, originalState)
+    return Promise.resolve('string')
+  }
+
+  function fn3 (state) {
+    t.strictEqual(state, originalState)
+    return Promise.resolve({ object: true })
+  }
+
+  function fn4 (state) {
+    t.strictEqual(state, originalState)
+  }
+})
+
+test('hooksRunner - Should handle when iterator errors', async (t) => {
+  t.plan(1)
+
+  try {
+    await hooksRunner([fn1, fn2], iterator, 'a')
+  } catch (err) {
+    t.strictEqual(err.message, 'kaboom')
+  }
+
+  function iterator (fn) {
+    throw new Error('kaboom')
+  }
+
+  function fn1 () {
+    t.fail('We should not be here')
+  }
+
+  function fn2 () {
+    t.fail('We should not be here')
+  }
+})
+
+test('preExecutionHooksRunner - Basic', (t) => {
+  t.plan(9)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  preExecutionHooksRunner([fn1, fn2, fn3], originalRequest)
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+
+  function fn2 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+
+  function fn3 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+})
+
+test('preExecutionHooksRunner - In case of error should skip subsequent functions', async (t) => {
+  t.plan(7)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  try {
+    await preExecutionHooksRunner([fn1, fn2, fn3], originalRequest)
+  } catch (err) {
+    t.strictEqual(err.message, 'kaboom')
+  }
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+
+  function fn2 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.reject(new Error('kaboom'))
+  }
+
+  function fn3 (schema, document, context) {
+    t.fail('We should not be here')
+  }
+})
+
+test('preExecutionHooksRunner - Promises that resolve to a value do not change the request or execution result', async (t) => {
+  t.plan(10)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  await preExecutionHooksRunner([fn1, fn2, fn3], originalRequest)
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve(null)
+  }
+
+  function fn2 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve('string')
+  }
+
+  function fn3 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve({ object: true })
+  }
+
+  t.deepEqual(originalRequest, { schema: 'schema', document: 'document', context: 'context' })
+})
+
+test('preExecutionHooksRunner - Promises can modify a query document', async (t) => {
+  t.plan(5)
+
+  const originalRequest = { schema: 'schema', document: { old: 'old' }, context: 'context' }
+
+  const { modifiedDocument } = await preExecutionHooksRunner([fn1], originalRequest)
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.deepEqual(document, { old: 'old' })
+    t.strictEqual(context, 'context')
+    return Promise.resolve({ document: { new: 'new' } })
+  }
+
+  t.deepEqual(originalRequest, { schema: 'schema', document: { old: 'old' }, context: 'context' })
+  t.deepEqual(modifiedDocument, { new: 'new' })
+})
+
+test('preExecutionHooksRunner - Promises can add to existing execution errors', async (t) => {
+  t.plan(5)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  const { errors } = await preExecutionHooksRunner([fn1], originalRequest)
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve({ errors: [{ message: 'new errors' }] })
+  }
+
+  t.deepEqual(originalRequest, { schema: 'schema', document: 'document', context: 'context' })
+  t.deepEqual(errors, [{ message: 'new errors' }])
+})
+
+test('preExecutionHooksRunner - Should handle thrown errors', async t => {
+  t.plan(8)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  try {
+    await preExecutionHooksRunner([fn1, fn2, fn3], originalRequest)
+  } catch (err) {
+    t.strictEqual(err.message, 'kaboom')
+    t.deepEqual(originalRequest, { schema: 'schema', document: 'document', context: 'context' })
+  }
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+
+  function fn2 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    throw new Error('kaboom')
+  }
+
+  function fn3 () {
+    t.fail('We should not be here')
+  }
+})
+
+test('preExecutionHooksRunner - Should handle non promise functions ', async t => {
+  t.plan(7)
+
+  const originalRequest = { schema: 'schema', document: 'document', context: 'context' }
+
+  await preExecutionHooksRunner([fn1, fn2], originalRequest)
+
+  function fn1 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+    return Promise.resolve()
+  }
+
+  function fn2 (schema, document, context) {
+    t.strictEqual(schema, 'schema')
+    t.strictEqual(document, 'document')
+    t.strictEqual(context, 'context')
+  }
+  t.deepEqual(originalRequest, { schema: 'schema', document: 'document', context: 'context' })
+})
diff --git a/test/types/index.ts b/test/types/index.ts
index 9dd931a..04ab507 100644
--- a/test/types/index.ts
+++ b/test/types/index.ts
@@ -463,3 +463,35 @@ query hello {
   helloWorld
 }
 `)
+
+app.graphql.addHook('preParsing', async function (schema, source, context) {
+  console.log('preParsing called')
+})
+
+app.graphql.addHook('preValidation', async function (schema, document, context) {
+  console.log('preValidation called')
+})
+
+app.graphql.addHook('preExecution', async function (schema, document, context) {
+  console.log('preExecution called')
+  return {
+    document,
+    errors: [
+      new Error('foo')
+    ]
+  }
+})
+
+app.graphql.addHook('preGatewayExecution', async function (schema, document, context) {
+  console.log('preGatewayExecution called')
+  return {
+    document,
+    errors: [
+      new Error('foo')
+    ]
+  }
+})
+
+app.graphql.addHook('onResolution', async function (execution, context) {
+  console.log('onResolution called')
+})