Repository: MichalLytek/type-graphql
Pull request #521 (2020-01-10 18:29:59)

Add @Extensions decorator
Hello :wave:

First of all, thank you very much for your work on this library, it has helped us a lot in rapidly creating a graphql endpoint to go hand in hand with our existing REST API 🙂 

In this PR, I will attempt to address this issue: https://github.com/MichalLytek/type-graphql/issues/124

I have tried to base my implementation off of the comments I could read in the issue and also in the PRs that were previously created for this. I also took inspiration from the Directive PR (https://github.com/MichalLytek/type-graphql/pull/369) which helped me by showcasing recent good PR practices on the project.

I will leave several comments of my own where I am unsure about my approach, I would be grateful if you could let me know your views on them, and also for any time you would spend reviewing the PR.


I won't be very active during the upcoming week-end, but I'll definitely try to address any feedback as fast as I can during the next week.

Best!


codecov[bot]: # [Codecov](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=h1) Report
> Merging [#521](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=desc) into [master](https://codecov.io/gh/MichalLytek/type-graphql/commit/fe6b3062d99e3544f6a95d44484b990f64b35abe?src=pr&el=desc) will **increase** coverage by `0.02%`.
> The diff coverage is `96.55%`.

[![Impacted file tree graph](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/graphs/tree.svg?width=650&token=Z6D63dJD2e&height=150&src=pr)](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #521      +/-   ##
==========================================
+ Coverage   95.18%   95.21%   +0.02%     
==========================================
  Files          76       77       +1     
  Lines        1330     1359      +29     
  Branches      257      261       +4     
==========================================
+ Hits         1266     1294      +28     
- Misses         61       62       +1     
  Partials        3        3
```


| [Impacted Files](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/schema/schema-generator.ts](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/diff?src=pr&el=tree#diff-c3JjL3NjaGVtYS9zY2hlbWEtZ2VuZXJhdG9yLnRz) | `96.74% <ø> (ø)` | :arrow_up: |
| [src/metadata/metadata-storage.ts](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/diff?src=pr&el=tree#diff-c3JjL21ldGFkYXRhL21ldGFkYXRhLXN0b3JhZ2UudHM=) | `100% <100%> (ø)` | :arrow_up: |
| [src/metadata/utils.ts](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/diff?src=pr&el=tree#diff-c3JjL21ldGFkYXRhL3V0aWxzLnRz) | `94.73% <100%> (+0.61%)` | :arrow_up: |
| [src/decorators/index.ts](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/diff?src=pr&el=tree#diff-c3JjL2RlY29yYXRvcnMvaW5kZXgudHM=) | `100% <100%> (ø)` | :arrow_up: |
| [src/decorators/Extensions.ts](https://codecov.io/gh/MichalLytek/type-graphql/pull/521/diff?src=pr&el=tree#diff-c3JjL2RlY29yYXRvcnMvRXh0ZW5zaW9ucy50cw==) | `88.88% <88.88%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=footer). Last update [fe6b306...de5ee0b](https://codecov.io/gh/MichalLytek/type-graphql/pull/521?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72ae37fa..34ada01a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,11 @@ ### Features
 - add basic support for directives with `@Directive()` decorator (#369)
 - add possibility to tune up the performance and disable auth & middlewares stack for simple field resolvers (#479)
 - optimize resolvers execution paths to speed up a lot basic scenarios (#488)
+- add `@Extensions` decorator for putting metadata into GraphQL types config (#521)
 ### Fixes
 - refactor union types function syntax handling to prevent possible errors with circular refs
 - fix transforming and validating nested inputs and arrays (#462)
+- remove duplicated entries for resolver classes that use inheritance (#499)
 ### Others
 - **Breaking Change**: change build config to ES2018 - drop support for Node.js < 10.3
 - **Breaking Change**: remove deprecated `DepreciationOptions` interface
diff --git a/dev.js b/dev.js
index fa953841..dd8f1830 100644
--- a/dev.js
+++ b/dev.js
@@ -4,6 +4,7 @@ require("ts-node/register/transpile-only");
 // require("./examples/authorization/index.ts");
 // require("./examples/automatic-validation/index.ts");
 // require("./examples/enums-and-unions/index.ts");
+// require("./examples/extensions/index.ts");
 // require("./examples/generic-types/index.ts");
 // require("./examples/interfaces-inheritance/index.ts");
 // require("./examples/middlewares-custom-decorators/index.ts");
diff --git a/docs/bootstrap.md b/docs/bootstrap.md
index 747ce33f..a015e3a2 100644
--- a/docs/bootstrap.md
+++ b/docs/bootstrap.md
@@ -39,7 +39,7 @@ ## Create Executable Schema
 
 ```typescript
 const schema = await buildSchema({
-  resolvers: [__dirname + "/modules/**/*.resolver.ts", __dirname + "/resolvers/**/*.ts"],
+  resolvers: [__dirname + "/modules/**/*.resolver.{ts,js}", __dirname + "/resolvers/**/*.{ts,js}"],
 });
 ```
 
@@ -54,7 +54,7 @@ ## Create Executable Schema
 
 async function bootstrap() {
   const schema = await buildSchema({
-    resolvers: [__dirname + "/**/*.resolver.ts"],
+    resolvers: [__dirname + "/**/*.resolver.{ts,js}"],
   });
 
   // other initialization code, like creating http server
diff --git a/docs/extensions.md b/docs/extensions.md
new file mode 100644
index 00000000..739720ec
--- /dev/null
+++ b/docs/extensions.md
@@ -0,0 +1,117 @@
+---
+title: Extensions
+---
+
+The `graphql-js` library allows for putting arbitrary data into GraphQL types config inside the `extensions` property.
+Annotating schema types or fields with a custom metadata, that can be then used at runtime by middlewares or resolvers, is a really powerful and useful feature.
+
+For such use cases, **TypeGraphQL** provides the `@Extensions` decorator, which adds the data we defined to the `extensions` property of the executable schema for the decorated classes, methods or properties.
+
+> Be aware that this is a low-level decorator and you generally have to provide your own logic to make use of the `extensions` metadata.
+
+## Using the `@Extensions` decorator
+
+Adding extensions to the schema type is as simple as using the `@Extensions` decorator and passing it an object of the custom data we want:
+
+```typescript
+@Extensions({ complexity: 2 })
+```
+
+We can pass several fields to the decorator:
+
+```typescript
+@Extensions({ logMessage: "Restricted access", logLevel: 1 })
+```
+
+And we can also decorate a type several times. The snippet below shows that this attaches the exact same extensions data to the schema type as the snippet above:
+
+```typescript
+@Extensions({ logMessage: "Restricted access" })
+@Extensions({ logLevel: 1 })
+```
+
+If we decorate the same type several times with the same extensions key, the one defined at the bottom takes precedence:
+
+```typescript
+@Extensions({ logMessage: "Restricted access" })
+@Extensions({ logMessage: "Another message" })
+```
+
+The above usage results in your GraphQL type having a `logMessage: "Another message"` property in its extensions.
+
+TypeGraphQL classes with the following decorators can be annotated with `@Extensions` decorator:
+
+- `@ObjectType`
+- `@InputType`
+- `@Field`
+- `@Query`
+- `@Mutation`
+- `@FieldResolver`
+
+So the `@Extensions` decorator can be placed over the class property/method or over the type class itself, and multiple times if necessary, depending on what we want to do with the extensions data:
+
+```typescript
+@Extensions({ roles: ["USER"] })
+@ObjectType()
+class Foo {
+  @Field()
+  field: string;
+}
+
+@ObjectType()
+class Bar {
+  @Extensions({ roles: ["USER"] })
+  @Field()
+  field: string;
+}
+
+@ObjectType()
+class Bar {
+  @Extensions({ roles: ["USER"] })
+  @Extensions({ visible: false, logMessage: "User accessed restricted field" })
+  @Field()
+  field: string;
+}
+
+@Resolver(of => Foo)
+class FooBarResolver {
+  @Extensions({ roles: ["USER"] })
+  @Query()
+  foobar(@Arg("baz") baz: string): string {
+    return "foobar";
+  }
+
+  @Extensions({ roles: ["ADMIN"] })
+  @FieldResolver()
+  bar(): string {
+    return "foobar";
+  }
+}
+```
+
+## Using the extensions data in runtime
+
+Once we have decorated the necessary types with extensions, the executable schema will contain the extensions data, and we can make use of it in any way we choose. The most common use will be to read it at runtime in resolvers or middlewares and perform some custom logic there.
+
+Here is a simple example of a global middleware that will be logging a message on field resolver execution whenever the field is decorated appropriately with `@Extensions`:
+
+```typescript
+export class LoggerMiddleware implements MiddlewareInterface<Context> {
+  constructor(private readonly logger: Logger) {}
+
+  use({ info }: ResolverData, next: NextFn) {
+    // extract `extensions` object from GraphQLResolveInfo object to get the `logMessage` value
+    const { logMessage } = info.parentType.getFields()[info.fieldName].extensions || {};
+
+    if (logMessage) {
+      this.logger.log(logMessage);
+    }
+
+    return next();
+  }
+}
+```
+
+## Examples
+
+You can see more detailed examples of usage [here](https://github.com/MichalLytek/type-graphql/tree/master/examples/extensions).
diff --git a/examples/extensions/context.interface.ts b/examples/extensions/context.interface.ts
new file mode 100644
index 00000000..095b5cf5
--- /dev/null
+++ b/examples/extensions/context.interface.ts
@@ -0,0 +1,5 @@
+import { User } from "./user.interface";
+
+export interface Context {
+  user?: User;
+}
diff --git a/examples/extensions/examples.gql b/examples/extensions/examples.gql
new file mode 100644
index 00000000..f57a92c2
--- /dev/null
+++ b/examples/extensions/examples.gql
@@ -0,0 +1,36 @@
+query GetPublicRecipes {
+  recipes {
+    title
+    description
+    averageRating
+  }
+}
+
+query GetRecipesForAuthedUser {
+  recipes {
+    title
+    description
+    ingredients
+    averageRating
+  }
+}
+
+query GetRecipesForAdmin {
+  recipes {
+    title
+    description
+    ingredients
+    averageRating
+    ratings
+  }
+}
+
+mutation AddRecipeByAuthedUser {
+  addRecipe(title: "Sample Recipe") {
+    averageRating
+  }
+}
+
+mutation DeleteRecipeByAdmin {
+  deleteRecipe(title: "Recipe 1")
+}
diff --git a/examples/extensions/helpers/config.extractors.ts b/examples/extensions/helpers/config.extractors.ts
new file mode 100644
index 00000000..630b277b
--- /dev/null
+++ b/examples/extensions/helpers/config.extractors.ts
@@ -0,0 +1,18 @@
+import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql";
+
+export const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig<any, any> => {
+  const { type, extensions, description, deprecationReason } = info.parentType.getFields()[
+    info.fieldName
+  ];
+
+  return {
+    type,
+    description,
+    extensions,
+    deprecationReason,
+  };
+};
+
+export const extractParentTypeConfig = (
+  info: GraphQLResolveInfo,
+): GraphQLObjectTypeConfig<any, any> => info.parentType.toConfig();
diff --git a/examples/extensions/helpers/recipe.ts b/examples/extensions/helpers/recipe.ts
new file mode 100644
index 00000000..12581c09
--- /dev/null
+++ b/examples/extensions/helpers/recipe.ts
@@ -0,0 +1,27 @@
+import { plainToClass } from "class-transformer";
+
+import { Recipe } from "../recipe.type";
+
+export function createRecipe(recipeData: Partial<Recipe>): Recipe {
+  return plainToClass(Recipe, recipeData);
+}
+
+export const sampleRecipes = [
+  createRecipe({
+    title: "Recipe 1",
+    description: "Desc 1",
+    ingredients: ["one", "two", "three"],
+    ratings: [3, 4, 5, 5, 5],
+  }),
+  createRecipe({
+    title: "Recipe 2",
+    description: "Desc 2",
+    ingredients: ["four", "five", "six"],
+    ratings: [3, 4, 5, 3, 2],
+  }),
+  createRecipe({
+    title: "Recipe 3",
+    ingredients: ["seven", "eight", "nine"],
+    ratings: [4, 4, 5, 5, 4],
+  }),
+];
diff --git a/examples/extensions/index.ts b/examples/extensions/index.ts
new file mode 100644
index 00000000..8d8e01e9
--- /dev/null
+++ b/examples/extensions/index.ts
@@ -0,0 +1,36 @@
+import "reflect-metadata";
+import { ApolloServer } from "apollo-server";
+import Container from "typedi";
+import { buildSchema } from "../../src";
+
+import { ExampleResolver } from "./resolver";
+import { Context } from "./context.interface";
+import { LoggerMiddleware } from "./logger.middleware";
+
+void (async function bootstrap() {
+  // build TypeGraphQL executable schema
+  const schema = await buildSchema({
+    container: Container,
+    resolvers: [ExampleResolver],
+    globalMiddlewares: [LoggerMiddleware],
+  });
+
+  // Create GraphQL server
+  const server = new ApolloServer({
+    schema,
+    context: () => {
+      const ctx: Context = {
+        // example user
+        user: {
+          id: 123,
+          name: "Sample user",
+        },
+      };
+      return ctx;
+    },
+  });
+
+  // Start the server
+  const { url } = await server.listen(4000);
+  console.log(`Server is running, GraphQL Playground available at ${url}`);
+})();
diff --git a/examples/extensions/log-message.decorator.ts b/examples/extensions/log-message.decorator.ts
new file mode 100644
index 00000000..b8a66ddb
--- /dev/null
+++ b/examples/extensions/log-message.decorator.ts
@@ -0,0 +1,20 @@
+import { Extensions } from "../../src";
+
+interface LogOptions {
+  message: string;
+  level?: number;
+}
+
+export function LogMessage(messageOrOptions: string | LogOptions) {
+  // parse the parameters of the custom decorator
+  const log: LogOptions =
+    typeof messageOrOptions === "string"
+      ? {
+          level: 4,
+          message: messageOrOptions,
+        }
+      : messageOrOptions;
+
+  // return the `@Extensions` decorator with a prepared property
+  return Extensions({ log });
+}
diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts
new file mode 100644
index 00000000..835fda8b
--- /dev/null
+++ b/examples/extensions/logger.middleware.ts
@@ -0,0 +1,39 @@
+import { Service } from "typedi";
+import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql";
+import { MiddlewareInterface, NextFn, ResolverData } from "../../src";
+
+import { extractFieldConfig, extractParentTypeConfig } from "./helpers/config.extractors";
+import { Context } from "./context.interface";
+import { Logger } from "./logger.service";
+
+const extractLoggerExtensionsFromConfig = (
+  config: GraphQLObjectTypeConfig<any, any> | GraphQLFieldConfig<any, any>,
+) => (config.extensions && config.extensions.log) || {};
+
+const getLoggerExtensions = (info: GraphQLResolveInfo) => {
+  const fieldConfig = extractFieldConfig(info);
+  const fieldLoggerExtensions = extractLoggerExtensionsFromConfig(fieldConfig);
+
+  const parentConfig = extractParentTypeConfig(info);
+  const parentLoggerExtensions = extractLoggerExtensionsFromConfig(parentConfig);
+
+  return {
+    ...parentLoggerExtensions,
+    ...fieldLoggerExtensions,
+  };
+};
+
+@Service()
+export class LoggerMiddleware implements MiddlewareInterface<Context> {
+  constructor(private readonly logger: Logger) {}
+
+  use({ context: { user }, info }: ResolverData<Context>, next: NextFn) {
+    const { message, level = 0 } = getLoggerExtensions(info);
+
+    if (message) {
+      this.logger.log(level, `${user ? ` (user: ${user.id})` : ""}`, message);
+    }
+
+    return next();
+  }
+}
diff --git a/examples/extensions/logger.service.ts b/examples/extensions/logger.service.ts
new file mode 100644
index 00000000..8b7623a7
--- /dev/null
+++ b/examples/extensions/logger.service.ts
@@ -0,0 +1,9 @@
+import { Service } from "typedi";
+
+@Service()
+export class Logger {
+  log(...args: any[]) {
+    // replace with more sophisticated solution :)
+    console.log(...args);
+  }
+}
diff --git a/examples/extensions/recipe.type.ts b/examples/extensions/recipe.type.ts
new file mode 100644
index 00000000..192c72ef
--- /dev/null
+++ b/examples/extensions/recipe.type.ts
@@ -0,0 +1,29 @@
+import { ObjectType, Extensions, Field, Int, Float } from "../../src";
+
+import { LogMessage } from "./log-message.decorator";
+
+@ObjectType()
+// log a message when any Recipe field is accessed
+@LogMessage("Recipe field accessed")
+export class Recipe {
+  @Field()
+  title: string;
+
+  @Field({ nullable: true })
+  description?: string;
+
+  @Field(type => [String])
+  // We can use raw Extensions decorator if we want
+  @Extensions({ log: { message: "ingredients field accessed", level: 0 } })
+  ingredients: string[];
+
+  // this will override the object type log message
+  @LogMessage("Ratings accessed")
+  @Field(type => [Int])
+  ratings: number[];
+
+  @Field(type => Float, { nullable: true })
+  get averageRating(): number | null {
+    return this.ratings.reduce((a, b) => a + b, 0) / this.ratings.length;
+  }
+}
diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts
new file mode 100644
index 00000000..4e627fbc
--- /dev/null
+++ b/examples/extensions/resolver.ts
@@ -0,0 +1,40 @@
+import { Resolver, Query, Mutation, Arg, Extensions } from "../../src";
+
+import { LogMessage } from "./log-message.decorator";
+import { Recipe } from "./recipe.type";
+import { createRecipe, sampleRecipes } from "./helpers/recipe";
+
+@Resolver()
+export class ExampleResolver {
+  private recipesData: Recipe[] = sampleRecipes.slice();
+
+  @Query(returns => [Recipe])
+  async recipes(): Promise<Recipe[]> {
+    return await this.recipesData;
+  }
+
+  @Mutation()
+  addRecipe(
+    @Arg("title") title: string,
+    @Arg("description", { nullable: true }) description?: string,
+  ): Recipe {
+    const newRecipe = createRecipe({
+      title,
+      description,
+      ratings: [],
+    });
+    this.recipesData.push(newRecipe);
+    return newRecipe;
+  }
+
+  @LogMessage("Recipe deletion requested")
+  @Mutation()
+  deleteRecipe(@Arg("title") title: string): boolean {
+    const foundRecipeIndex = this.recipesData.findIndex(it => it.title === title);
+    if (!foundRecipeIndex) {
+      return false;
+    }
+    this.recipesData.splice(foundRecipeIndex, 1);
+    return true;
+  }
+}
diff --git a/examples/extensions/user.interface.ts b/examples/extensions/user.interface.ts
new file mode 100644
index 00000000..4a77c787
--- /dev/null
+++ b/examples/extensions/user.interface.ts
@@ -0,0 +1,4 @@
+export interface User {
+  id: number;
+  name: string;
+}
diff --git a/src/decorators/Extensions.ts b/src/decorators/Extensions.ts
new file mode 100644
index 00000000..276f300d
--- /dev/null
+++ b/src/decorators/Extensions.ts
@@ -0,0 +1,27 @@
+import { MethodAndPropDecorator } from "./types";
+import { SymbolKeysNotSupportedError } from "../errors";
+import { getMetadataStorage } from "../metadata/getMetadataStorage";
+import { ExtensionsMetadata } from "../metadata/definitions";
+
+export function Extensions(extensions: ExtensionsMetadata): MethodAndPropDecorator & ClassDecorator;
+export function Extensions(
+  extensions: ExtensionsMetadata,
+): MethodDecorator | PropertyDecorator | ClassDecorator {
+  return (targetOrPrototype, propertyKey, descriptor) => {
+    if (typeof propertyKey === "symbol") {
+      throw new SymbolKeysNotSupportedError();
+    }
+    if (propertyKey) {
+      getMetadataStorage().collectExtensionsFieldMetadata({
+        target: targetOrPrototype.constructor,
+        fieldName: propertyKey,
+        extensions,
+      });
+    } else {
+      getMetadataStorage().collectExtensionsClassMetadata({
+        target: targetOrPrototype as Function,
+        extensions,
+      });
+    }
+  };
+}
diff --git a/src/decorators/index.ts b/src/decorators/index.ts
index 3d2c7706..720d8f11 100644
--- a/src/decorators/index.ts
+++ b/src/decorators/index.ts
@@ -6,6 +6,7 @@ export { createParamDecorator } from "./createParamDecorator";
 export { createMethodDecorator } from "./createMethodDecorator";
 export { Ctx } from "./Ctx";
 export { Directive } from "./Directive";
+export { Extensions } from "./Extensions";
 export { registerEnumType } from "./enums";
 export { Field, FieldOptions } from "./Field";
 export { FieldResolver } from "./FieldResolver";
diff --git a/src/metadata/definitions/class-metadata.ts b/src/metadata/definitions/class-metadata.ts
index e15fd7b8..0155622d 100644
--- a/src/metadata/definitions/class-metadata.ts
+++ b/src/metadata/definitions/class-metadata.ts
@@ -1,5 +1,6 @@
 import { FieldMetadata } from "./field-metadata";
 import { DirectiveMetadata } from "./directive-metadata";
+import { ExtensionsMetadata } from "./extensions-metadata";
 
 export interface ClassMetadata {
   name: string;
@@ -8,5 +9,6 @@ export interface ClassMetadata {
   description?: string;
   isAbstract?: boolean;
   directives?: DirectiveMetadata[];
+  extensions?: ExtensionsMetadata;
   simpleResolvers?: boolean;
 }
diff --git a/src/metadata/definitions/extensions-metadata.ts b/src/metadata/definitions/extensions-metadata.ts
new file mode 100644
index 00000000..3b1ada97
--- /dev/null
+++ b/src/metadata/definitions/extensions-metadata.ts
@@ -0,0 +1,12 @@
+export type ExtensionsMetadata = Readonly<Record<string, any>>;
+
+export interface ExtensionsClassMetadata {
+  target: Function;
+  extensions: ExtensionsMetadata;
+}
+
+export interface ExtensionsFieldMetadata {
+  target: Function;
+  fieldName: string;
+  extensions: ExtensionsMetadata;
+}
diff --git a/src/metadata/definitions/field-metadata.ts b/src/metadata/definitions/field-metadata.ts
index 0c0855cf..d5ce485c 100644
--- a/src/metadata/definitions/field-metadata.ts
+++ b/src/metadata/definitions/field-metadata.ts
@@ -3,6 +3,7 @@ import { TypeValueThunk, TypeOptions } from "../../decorators/types";
 import { Middleware } from "../../interfaces/Middleware";
 import { Complexity } from "../../interfaces";
 import { DirectiveMetadata } from "./directive-metadata";
+import { ExtensionsMetadata } from "./extensions-metadata";
 
 export interface FieldMetadata {
   target: Function;
@@ -17,5 +18,6 @@ export interface FieldMetadata {
   roles?: any[];
   middlewares?: Array<Middleware<any>>;
   directives?: DirectiveMetadata[];
+  extensions?: ExtensionsMetadata;
   simple?: boolean;
 }
diff --git a/src/metadata/definitions/index.ts b/src/metadata/definitions/index.ts
index 70a7d6ea..ff67f590 100644
--- a/src/metadata/definitions/index.ts
+++ b/src/metadata/definitions/index.ts
@@ -2,6 +2,7 @@ export * from "./authorized-metadata";
 export * from "./class-metadata";
 export * from "./directive-metadata";
 export * from "./enum-metadata";
+export * from "./extensions-metadata";
 export * from "./field-metadata";
 export * from "./middleware-metadata";
 export * from "./param-metadata";
diff --git a/src/metadata/definitions/resolver-metadata.ts b/src/metadata/definitions/resolver-metadata.ts
index be349ba3..913039be 100644
--- a/src/metadata/definitions/resolver-metadata.ts
+++ b/src/metadata/definitions/resolver-metadata.ts
@@ -11,6 +11,7 @@ import { ParamMetadata } from "./param-metadata";
 import { Middleware } from "../../interfaces/Middleware";
 import { Complexity } from "../../interfaces";
 import { DirectiveMetadata } from "./directive-metadata";
+import { ExtensionsMetadata } from "./extensions-metadata";
 
 export interface BaseResolverMetadata {
   methodName: string;
@@ -22,6 +23,7 @@ export interface BaseResolverMetadata {
   roles?: any[];
   middlewares?: Array<Middleware<any>>;
   directives?: DirectiveMetadata[];
+  extensions?: ExtensionsMetadata;
 }
 
 export interface ResolverMetadata extends BaseResolverMetadata {
diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts
index 74b3c2d0..7a422654 100644
--- a/src/metadata/metadata-storage.ts
+++ b/src/metadata/metadata-storage.ts
@@ -1,6 +1,8 @@
 import {
   ResolverMetadata,
   ClassMetadata,
+  ExtensionsClassMetadata,
+  ExtensionsFieldMetadata,
   FieldMetadata,
   ParamMetadata,
   FieldResolverMetadata,
@@ -12,6 +14,7 @@ import {
   ResolverClassMetadata,
   SubscriptionResolverMetadata,
   MiddlewareMetadata,
+  ExtensionsMetadata,
 } from "./definitions";
 import { ClassType } from "../interfaces";
 import { NoExplicitTypeError } from "../errors";
@@ -40,6 +43,8 @@ export class MetadataStorage {
   middlewares: MiddlewareMetadata[] = [];
   classDirectives: DirectiveClassMetadata[] = [];
   fieldDirectives: DirectiveFieldMetadata[] = [];
+  classExtensions: ExtensionsClassMetadata[] = [];
+  fieldExtensions: ExtensionsFieldMetadata[] = [];
 
   private resolverClasses: ResolverClassMetadata[] = [];
   private fields: FieldMetadata[] = [];
@@ -108,11 +113,20 @@ export class MetadataStorage {
     this.fieldDirectives.push(definition);
   }
 
+  collectExtensionsClassMetadata(definition: ExtensionsClassMetadata) {
+    this.classExtensions.push(definition);
+  }
+  collectExtensionsFieldMetadata(definition: ExtensionsFieldMetadata) {
+    this.fieldExtensions.push(definition);
+  }
+
   build() {
     // TODO: disable next build attempts
 
     this.classDirectives.reverse();
     this.fieldDirectives.reverse();
+    this.classExtensions.reverse();
+    this.fieldExtensions.reverse();
 
     this.buildClassMetadata(this.objectTypes);
     this.buildClassMetadata(this.inputTypes);
@@ -143,6 +157,8 @@ export class MetadataStorage {
     this.middlewares = [];
     this.classDirectives = [];
     this.fieldDirectives = [];
+    this.classExtensions = [];
+    this.fieldExtensions = [];
 
     this.resolverClasses = [];
     this.fields = [];
@@ -167,6 +183,7 @@ export class MetadataStorage {
           field.directives = this.fieldDirectives
             .filter(it => it.target === field.target && it.fieldName === field.name)
             .map(it => it.directive);
+          field.extensions = this.findExtensions(field.target, field.name);
         });
         def.fields = fields;
       }
@@ -175,6 +192,9 @@ export class MetadataStorage {
           .filter(it => it.target === def.target)
           .map(it => it.directive);
       }
+      if (!def.extensions) {
+        def.extensions = this.findExtensions(def.target);
+      }
     });
   }
 
@@ -196,6 +216,7 @@ export class MetadataStorage {
       def.directives = this.fieldDirectives
         .filter(it => it.target === def.target && it.fieldName === def.methodName)
         .map(it => it.directive);
+      def.extensions = this.findExtensions(def.target, def.methodName);
     });
   }
 
@@ -206,6 +227,7 @@ export class MetadataStorage {
       def.directives = this.fieldDirectives
         .filter(it => it.target === def.target && it.fieldName === def.methodName)
         .map(it => it.directive);
+      def.extensions = this.findExtensions(def.target, def.methodName);
       def.getObjectType =
         def.kind === "external"
           ? this.resolverClasses.find(resolver => resolver.target === def.target)!.getObjectType
@@ -236,6 +258,7 @@ export class MetadataStorage {
             middlewares: def.middlewares!,
             params: def.params!,
             directives: def.directives,
+            extensions: def.extensions,
           };
           this.collectClassFieldMetadata(fieldMetadata);
           objectType.fields!.push(fieldMetadata);
@@ -263,13 +286,13 @@ export class MetadataStorage {
       while (superResolver.prototype) {
         const superResolverMetadata = this.resolverClasses.find(it => it.target === superResolver);
         if (superResolverMetadata) {
-          this.queries.unshift(...mapSuperResolverHandlers(this.queries, superResolver, def));
-          this.mutations.unshift(...mapSuperResolverHandlers(this.mutations, superResolver, def));
-          this.subscriptions.unshift(
-            ...mapSuperResolverHandlers(this.subscriptions, superResolver, def),
-          );
-          this.fieldResolvers.unshift(
-            ...mapSuperFieldResolverHandlers(this.fieldResolvers, superResolver, def),
+          this.queries = mapSuperResolverHandlers(this.queries, superResolver, def);
+          this.mutations = mapSuperResolverHandlers(this.mutations, superResolver, def);
+          this.subscriptions = mapSuperResolverHandlers(this.subscriptions, superResolver, def);
+          this.fieldResolvers = mapSuperFieldResolverHandlers(
+            this.fieldResolvers,
+            superResolver,
+            def,
           );
         }
         superResolver = Object.getPrototypeOf(superResolver);
@@ -286,4 +309,16 @@ export class MetadataStorage {
     }
     return authorizedField.roles;
   }
+
+  private findExtensions(target: Function, fieldName?: string): ExtensionsMetadata {
+    const storedExtensions: Array<ExtensionsClassMetadata | ExtensionsFieldMetadata> = fieldName
+      ? this.fieldExtensions
+      : this.classExtensions;
+    return storedExtensions
+      .filter(
+        entry =>
+          entry.target === target && (!("fieldName" in entry) || entry.fieldName === fieldName),
+      )
+      .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {});
+  }
 }
diff --git a/src/metadata/utils.ts b/src/metadata/utils.ts
index 5e100d49..e2672b41 100644
--- a/src/metadata/utils.ts
+++ b/src/metadata/utils.ts
@@ -3,6 +3,9 @@ import {
   BaseResolverMetadata,
   MiddlewareMetadata,
   FieldResolverMetadata,
+  ExtensionsClassMetadata,
+  ExtensionsFieldMetadata,
+  ExtensionsMetadata,
 } from "./definitions";
 import { Middleware } from "../interfaces/Middleware";
 import { isThrowing } from "../helpers/isThrowing";
@@ -13,28 +16,34 @@ export function mapSuperResolverHandlers<T extends BaseResolverMetadata>(
   superResolver: Function,
   resolverMetadata: ResolverClassMetadata,
 ): T[] {
-  const superMetadata = definitions.filter(subscription => subscription.target === superResolver);
-
-  return superMetadata.map<T>(metadata => ({
-    ...(metadata as any),
-    target: resolverMetadata.target,
-    resolverClassMetadata: resolverMetadata,
-  }));
+  return definitions.map(metadata => {
+    return metadata.target === superResolver
+      ? {
+          ...metadata,
+          target: resolverMetadata.target,
+          resolverClassMetadata: resolverMetadata,
+        }
+      : metadata;
+  });
 }
 
 export function mapSuperFieldResolverHandlers(
   definitions: FieldResolverMetadata[],
   superResolver: Function,
   resolverMetadata: ResolverClassMetadata,
-): FieldResolverMetadata[] {
+) {
   const superMetadata = mapSuperResolverHandlers(definitions, superResolver, resolverMetadata);
 
-  return superMetadata.map<FieldResolverMetadata>(metadata => ({
-    ...metadata,
-    getObjectType: isThrowing(metadata.getObjectType!)
-      ? resolverMetadata.getObjectType!
-      : metadata.getObjectType!,
-  }));
+  return superMetadata.map(metadata => {
+    return metadata.target === superResolver
+      ? {
+          ...metadata,
+          getObjectType: isThrowing(metadata.getObjectType!)
+            ? resolverMetadata.getObjectType
+            : metadata.getObjectType,
+        }
+      : metadata;
+  });
 }
 
 export function mapMiddlewareMetadataToArray(
diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts
index f649d1b3..3afa5655 100644
--- a/src/schema/schema-generator.ts
+++ b/src/schema/schema-generator.ts
@@ -282,6 +282,7 @@ export abstract class SchemaGenerator {
           name: objectType.name,
           description: objectType.description,
           astNode: getObjectTypeDefinitionNode(objectType.name, objectType.directives),
+          extensions: objectType.extensions,
           interfaces: () => {
             let interfaces = interfaceClasses.map<GraphQLInterfaceType>(
               interfaceClass =>
@@ -331,6 +332,7 @@ export abstract class SchemaGenerator {
                   astNode: getFieldDefinitionNode(field.name, type, field.directives),
                   extensions: {
                     complexity: field.complexity,
+                    ...field.extensions,
                   },
                 };
                 return fieldsMap;
@@ -379,6 +381,7 @@ export abstract class SchemaGenerator {
         type: new GraphQLInputObjectType({
           name: inputType.name,
           description: inputType.description,
+          extensions: inputType.extensions,
           fields: () => {
             let fields = inputType.fields!.reduce<GraphQLInputFieldConfigMap>(
               (fieldsMap, field) => {
@@ -399,6 +402,7 @@ export abstract class SchemaGenerator {
                   type,
                   defaultValue: field.typeOptions.defaultValue,
                   astNode: getInputValueDefinitionNode(field.name, type, field.directives),
+                  extensions: field.extensions,
                 };
                 return fieldsMap;
               },
@@ -498,6 +502,7 @@ export abstract class SchemaGenerator {
         astNode: getFieldDefinitionNode(handler.schemaName, type, handler.directives),
         extensions: {
           complexity: handler.complexity,
+          ...handler.extensions,
         },
       };
       return fields;
diff --git a/website/i18n/en.json b/website/i18n/en.json
index 3ac37bfa..31acba4c 100644
--- a/website/i18n/en.json
+++ b/website/i18n/en.json
@@ -36,6 +36,9 @@
         "title": "Examples",
         "sidebar_label": "List of examples"
       },
+      "extensions": {
+        "title": "Extensions"
+      },
       "faq": {
         "title": "Frequently Asked Questions"
       },
diff --git a/website/sidebars.json b/website/sidebars.json
index 5ac509ed..281cbeae 100644
--- a/website/sidebars.json
+++ b/website/sidebars.json
@@ -8,7 +8,15 @@
       "resolvers",
       "bootstrap"
     ],
-    "Advanced guides": ["scalars", "enums", "unions", "interfaces", "subscriptions", "directives"],
+    "Advanced guides": [
+      "scalars",
+      "enums",
+      "unions",
+      "interfaces",
+      "subscriptions",
+      "directives",
+      "extensions"
+    ],
     "Features": [
       "dependency-injection",
       "authorization",
diff --git a/website/versioned_docs/version-0.17.1/bootstrap.md b/website/versioned_docs/version-0.17.1/bootstrap.md
index daeb3029..50efcb45 100644
--- a/website/versioned_docs/version-0.17.1/bootstrap.md
+++ b/website/versioned_docs/version-0.17.1/bootstrap.md
@@ -26,7 +26,7 @@ ## Create Executable Schema
 
 ```typescript
 const schema = await buildSchema({
-  resolvers: [__dirname + "/modules/**/*.resolver.ts", __dirname + "/resolvers/**/*.ts"],
+  resolvers: [__dirname + "/modules/**/*.resolver.{ts,js}", __dirname + "/resolvers/**/*.{ts,js}"],
 });
 ```
 
@@ -39,7 +39,7 @@ ## Create Executable Schema
 
 async function bootstrap() {
   const schema = await buildSchema({
-    resolvers: [__dirname + "/**/*.resolver.ts"],
+    resolvers: [__dirname + "/**/*.resolver.{ts,js}"],
   });
 
   // other initialization code, like creating http server


Test patch:
diff --git a/tests/functional/extensions.ts b/tests/functional/extensions.ts
new file mode 100644
index 00000000..15eb3717
--- /dev/null
+++ b/tests/functional/extensions.ts
@@ -0,0 +1,268 @@
+import "reflect-metadata";
+
+import { GraphQLSchema, GraphQLInputObjectType, GraphQLObjectType, GraphQLFieldMap } from "graphql";
+import {
+  Field,
+  InputType,
+  Resolver,
+  Query,
+  Arg,
+  Extensions,
+  buildSchema,
+  ObjectType,
+  Mutation,
+  FieldResolver,
+} from "../../src";
+import { getMetadataStorage } from "../../src/metadata/getMetadataStorage";
+
+describe("Extensions", () => {
+  let schema: GraphQLSchema;
+
+  describe("Schema", () => {
+    beforeAll(async () => {
+      getMetadataStorage().clear();
+
+      @InputType()
+      class ExtensionsOnFieldInput {
+        @Field()
+        @Extensions({ role: "admin" })
+        withExtensions: string;
+      }
+
+      @InputType()
+      @Extensions({ roles: ["admin", "user"] })
+      class ExtensionsOnClassInput {
+        @Field()
+        regularField: string;
+      }
+
+      @ObjectType()
+      @Extensions({ id: 1234 })
+      class ExtensionsOnClassObjectType {
+        @Field()
+        regularField: string;
+      }
+
+      @ObjectType()
+      class SampleObjectType {
+        @Field()
+        @Extensions({ role: "user" })
+        withExtensions: string = "withExtensions";
+
+        @Field()
+        @Extensions({ first: "first value", second: "second value" })
+        withMultipleExtensions: string = "withMultipleExtensions";
+
+        @Field()
+        @Extensions({ first: "first value" })
+        @Extensions({ second: "second value", third: "third value" })
+        withMultipleExtensionsDecorators: string = "hello";
+
+        @Field()
+        @Extensions({ duplicate: "first value" })
+        @Extensions({ duplicate: "second value" })
+        withConflictingExtensionsKeys: string = "hello";
+
+        @Field()
+        withInput(@Arg("input") input: ExtensionsOnFieldInput): string {
+          return `hello${input.withExtensions}`;
+        }
+
+        @Field()
+        @Extensions({ other: "extension" })
+        withInputAndField(@Arg("input") input: ExtensionsOnFieldInput): string {
+          return `hello${input.withExtensions}`;
+        }
+
+        @Field()
+        withInputOnClass(@Arg("input") input: ExtensionsOnClassInput): string {
+          return `hello${input.regularField}`;
+        }
+
+        @Field()
+        @Extensions({ other: "extension" })
+        withInputAndFieldOnClass(@Arg("input") input: ExtensionsOnClassInput): string {
+          return `hello${input.regularField}`;
+        }
+      }
+
+      @Resolver()
+      class SampleResolver {
+        @Query(() => SampleObjectType)
+        sampleObjectType(): SampleObjectType {
+          return new SampleObjectType();
+        }
+
+        @Query(() => ExtensionsOnClassObjectType)
+        extensionsOnClassObjectType(): ExtensionsOnClassObjectType {
+          return new ExtensionsOnClassObjectType();
+        }
+
+        @Query()
+        @Extensions({ mandatory: true })
+        queryWithExtensions(): string {
+          return "queryWithExtensions";
+        }
+
+        @Query()
+        @Extensions({ first: "first query value", second: "second query value" })
+        queryWithMultipleExtensions(): string {
+          return "hello";
+        }
+
+        @Query()
+        @Extensions({ first: "first query value" })
+        @Extensions({ second: "second query value", third: "third query value" })
+        queryWithMultipleExtensionsDecorators(): string {
+          return "hello";
+        }
+
+        @Mutation()
+        @Extensions({ mandatory: false })
+        mutationWithExtensions(): string {
+          return "mutationWithExtensions";
+        }
+
+        @Mutation()
+        @Extensions({ first: "first mutation value", second: "second mutation value" })
+        mutationWithMultipleExtensions(): string {
+          return "mutationWithMultipleExtensions";
+        }
+
+        @Mutation()
+        @Extensions({ first: "first mutation value" })
+        @Extensions({ second: "second mutation value", third: "third mutation value" })
+        mutationWithMultipleExtensionsDecorators(): string {
+          return "mutationWithMultipleExtensionsDecorators";
+        }
+      }
+
+      @Resolver(of => SampleObjectType)
+      class SampleObjectTypeResolver {
+        @FieldResolver()
+        @Extensions({ some: "extension" })
+        fieldResolverWithExtensions(): string {
+          return "hello";
+        }
+      }
+
+      schema = await buildSchema({
+        resolvers: [SampleResolver, SampleObjectTypeResolver],
+      });
+    });
+
+    it("should generate schema without errors", async () => {
+      expect(schema).toBeDefined();
+    });
+
+    describe("Fields", () => {
+      let fields: GraphQLFieldMap<any, any>;
+
+      beforeAll(async () => {
+        fields = (schema.getType("SampleObjectType") as GraphQLObjectType).getFields();
+      });
+
+      it("should add simple extensions to object fields", async () => {
+        expect(fields.withExtensions.extensions).toEqual({ role: "user" });
+      });
+
+      it("should add extensions with multiple properties to object fields", async () => {
+        expect(fields.withMultipleExtensions.extensions).toEqual({
+          first: "first value",
+          second: "second value",
+        });
+      });
+
+      it("should allow multiple extensions decorators for object fields", async () => {
+        expect(fields.withMultipleExtensionsDecorators.extensions).toEqual({
+          first: "first value",
+          second: "second value",
+          third: "third value",
+        });
+      });
+
+      it("should override extensions values when duplicate keys are provided", async () => {
+        expect(fields.withConflictingExtensionsKeys.extensions).toEqual({
+          duplicate: "second value",
+        });
+      });
+    });
+
+    describe("Query", () => {
+      it("should add simple extensions to query types", async () => {
+        const { queryWithExtensions } = schema.getQueryType()!.getFields();
+        expect(queryWithExtensions.extensions).toEqual({ mandatory: true });
+      });
+
+      it("should add extensions with multiple properties to query types", async () => {
+        const { queryWithMultipleExtensions } = schema.getQueryType()!.getFields();
+        expect(queryWithMultipleExtensions.extensions).toEqual({
+          first: "first query value",
+          second: "second query value",
+        });
+      });
+
+      it("should allow multiple extensions decorators for query types", async () => {
+        const { queryWithMultipleExtensionsDecorators } = schema.getQueryType()!.getFields();
+        expect(queryWithMultipleExtensionsDecorators.extensions).toEqual({
+          first: "first query value",
+          second: "second query value",
+          third: "third query value",
+        });
+      });
+    });
+
+    describe("Mutation", () => {
+      it("should add simple extensions to mutation types", async () => {
+        const { mutationWithExtensions } = schema.getMutationType()!.getFields();
+        expect(mutationWithExtensions.extensions).toEqual({ mandatory: false });
+      });
+
+      it("should add extensions with multiple properties to mutation types", async () => {
+        const { mutationWithMultipleExtensions } = schema.getMutationType()!.getFields();
+        expect(mutationWithMultipleExtensions.extensions).toEqual({
+          first: "first mutation value",
+          second: "second mutation value",
+        });
+      });
+
+      it("should allow multiple extensions decorators for mutation types", async () => {
+        const { mutationWithMultipleExtensionsDecorators } = schema.getMutationType()!.getFields();
+        expect(mutationWithMultipleExtensionsDecorators.extensions).toEqual({
+          first: "first mutation value",
+          second: "second mutation value",
+          third: "third mutation value",
+        });
+      });
+    });
+
+    describe("ObjectType", () => {
+      it("should add extensions to object types", async () => {
+        const objectType = schema.getType("ExtensionsOnClassObjectType") as GraphQLObjectType;
+        expect(objectType.extensions).toEqual({ id: 1234 });
+      });
+    });
+
+    describe("InputType", () => {
+      it("should add extensions to input types", async () => {
+        const inputType = schema.getType("ExtensionsOnClassInput") as GraphQLInputObjectType;
+        expect(inputType.extensions).toEqual({ roles: ["admin", "user"] });
+      });
+
+      it("should add extensions to input type fields", async () => {
+        const fields = (schema.getType(
+          "ExtensionsOnFieldInput",
+        ) as GraphQLInputObjectType).getFields();
+
+        expect(fields.withExtensions.extensions).toEqual({ role: "admin" });
+      });
+    });
+
+    describe("FieldResolver", () => {
+      it("should add extensions to field resolvers", async () => {
+        const fields = (schema.getType("SampleObjectType") as GraphQLObjectType).getFields();
+        expect(fields.fieldResolverWithExtensions.extensions).toEqual({ some: "extension" });
+      });
+    });
+  });
+});
diff --git a/tests/functional/metadata-storage.ts b/tests/functional/metadata-storage.ts
new file mode 100644
index 00000000..a2aed7bc
--- /dev/null
+++ b/tests/functional/metadata-storage.ts
@@ -0,0 +1,121 @@
+import "reflect-metadata";
+
+import { getMetadataStorage } from "../../src/metadata/getMetadataStorage";
+import {
+  Resolver,
+  Query,
+  buildSchema,
+  Mutation,
+  Subscription,
+  FieldResolver,
+  ObjectType,
+  ClassType,
+  Field,
+} from "../../src";
+
+describe("MetadataStorage", () => {
+  describe("resolvers inheritance", () => {
+    const INHERITED_QUERY_NAME = "inheritedQueryName";
+    const INHERITED_MUTATION_NAME = "inheritedMutationName";
+    const INHERITED_SUBSCRIPTION_NAME = "inheritedSubscriptionName";
+    const INHERITED_FIELD_RESOLVER_NAME = "inheritedFieldResolverName";
+
+    beforeAll(async () => {
+      getMetadataStorage().clear();
+
+      function createAbstractResolver(classType: ClassType) {
+        @Resolver(() => classType, { isAbstract: true })
+        abstract class AbstractResolver {
+          @Query({ name: INHERITED_QUERY_NAME })
+          abstractQuery(): boolean {
+            return true;
+          }
+
+          @Mutation({ name: INHERITED_MUTATION_NAME })
+          abstractMutation(): boolean {
+            return true;
+          }
+
+          @Subscription({ name: INHERITED_SUBSCRIPTION_NAME, topics: "sampleTopic" })
+          abstractSubscription(): boolean {
+            return true;
+          }
+
+          @FieldResolver({ name: INHERITED_FIELD_RESOLVER_NAME })
+          abstractFieldResolver(): boolean {
+            return true;
+          }
+        }
+        return AbstractResolver;
+      }
+
+      @ObjectType()
+      class SampleObject {
+        @Field()
+        sampleField: boolean;
+
+        @Field({ name: INHERITED_FIELD_RESOLVER_NAME })
+        abstractSampleField: boolean;
+      }
+
+      @Resolver(() => SampleObject)
+      class SubClassResolver extends createAbstractResolver(SampleObject) {
+        @Query()
+        subClassQuery(): boolean {
+          return true;
+        }
+
+        @Mutation()
+        subClassMutation(): boolean {
+          return true;
+        }
+
+        @Subscription({ topics: "sampleTopic" })
+        subClassSubscription(): boolean {
+          return true;
+        }
+
+        @FieldResolver()
+        sampleField(): boolean {
+          return true;
+        }
+      }
+
+      await buildSchema({ resolvers: [SubClassResolver] });
+    });
+
+    it("should not have duplicated query metadata for inherited resolvers", async () => {
+      expect(
+        getMetadataStorage().queries.filter(query => query.schemaName === INHERITED_QUERY_NAME),
+      ).toHaveLength(1);
+      expect(getMetadataStorage().queries).toHaveLength(2);
+    });
+
+    it("should not have duplicated mutation metadata for inherited resolvers", async () => {
+      expect(
+        getMetadataStorage().mutations.filter(
+          mutation => mutation.schemaName === INHERITED_MUTATION_NAME,
+        ),
+      ).toHaveLength(1);
+      expect(getMetadataStorage().mutations).toHaveLength(2);
+    });
+
+    it("should not have duplicated subscription metadata for inherited resolvers", async () => {
+      expect(
+        getMetadataStorage().subscriptions.filter(
+          subscription => subscription.schemaName === INHERITED_SUBSCRIPTION_NAME,
+        ),
+      ).toHaveLength(1);
+      expect(getMetadataStorage().subscriptions).toHaveLength(2);
+    });
+
+    it("should not have duplicated fieldResolver metadata for inherited resolvers", async () => {
+      expect(
+        getMetadataStorage().fieldResolvers.filter(
+          fieldResolver => fieldResolver.schemaName === INHERITED_FIELD_RESOLVER_NAME,
+        ),
+      ).toHaveLength(1);
+      expect(getMetadataStorage().fieldResolvers).toHaveLength(2);
+    });
+  });
+});