The @link directive enables schema composition by importing types and directives from external schemas. This powerful feature allows you to build modular GraphQL schemas and is the foundation for specifications like Apollo Federation.

Overview

The @link directive is applied to the schema definition and allows you to:

  • Import types and directives from external schema specifications
  • Use type aliasing to avoid naming conflicts
  • Compose schemas from multiple sources
  • Enable federation and other advanced GraphQL patterns

Tanka GraphQL provides complete support for the @link directive including:

  • ✅ Basic imports by name
  • ✅ Type and directive aliasing
  • ✅ Namespace prefixing with as parameter
  • ✅ Mixed aliased and non-aliased imports
  • ✅ Custom schema loaders
  • ✅ Circular reference protection
  • ✅ Integration with Apollo Federation

Basic Usage

[Fact]
public async Task BasicLinkUsage()
{
    var schema = """
        extend schema @link(
          url: "https://mycompany.com/schemas/auth/v1.0",
          import: ["@authenticated", "@authorized", "Role"]
        )

        type Query {
          profile: User @authenticated
          admin: AdminPanel @authorized(role: ADMIN)
        }

        type User {
            id: ID!
            name: String
        }

        type AdminPanel {
            settings: String
        }
        """;

    var builtSchema = await new SchemaBuilder()
        .Add(schema)
        .Build(options =>
        {
            options.SchemaLoader = new CustomSchemaLoader();
        });

    Assert.NotNull(builtSchema);
    Assert.NotNull(builtSchema.GetNamedType("User"));
    Assert.NotNull(builtSchema.GetDirectiveType("authenticated"));
    Assert.NotNull(builtSchema.GetNamedType("Role")); // Imported from external schema
}

Syntax

The @link directive follows the GraphQL specification for schema composition:

  • url (required): The URL of the specification to import from
  • as: Namespace prefix for imported types (e.g., federation would prefix types as federation__Type)
  • import: List of specific types and directives to import
  • for: Purpose of the link (SECURITY or EXECUTION)

Import Syntax

The import parameter accepts several formats:

Simple Import

Import types and directives by name using string literals.

Import with Aliasing

Rename imported types to avoid conflicts.

[Fact]
public async Task ImportWithAliasing()
{
    var schema = """
        extend schema @link(
          url: "https://mycompany.com/schemas/auth/v1.0",
          import: [
            { name: "@authenticated", as: "@requiresAuth" },
            { name: "Role", as: "UserRole" }
          ]
        )

        type Query {
            profile: User
            admin: AdminPanel
        }

        type User {
            id: ID!
            name: String
        }

        type AdminPanel {
            settings: String
        }
        """;

    var builtSchema = await new SchemaBuilder()
        .Add(schema)
        .Build(options =>
        {
            options.SchemaLoader = new CustomSchemaLoader();
        });

    Assert.NotNull(builtSchema);
    Assert.NotNull(builtSchema.GetNamedType("User"));

    // Verify that aliasing worked correctly
    Assert.NotNull(builtSchema.GetDirectiveType("requiresAuth")); // Aliased directive
    Assert.NotNull(builtSchema.GetNamedType("UserRole")); // Aliased type
    Assert.Null(builtSchema.GetDirectiveType("authenticated")); // Original should not exist
    Assert.Null(builtSchema.GetNamedType("Role")); // Original should not exist
}

How It Works

The @link directive is processed by the LinkProcessingMiddleware during schema building:

  1. Directive Discovery: @link directives are extracted from schema definitions and extensions
  2. URL Resolution: The URL is resolved to determine the appropriate schema loader
  3. Schema Loading: The schema loader fetches the external specification
  4. Import Parsing: Import specifications are parsed, including aliases
  5. Import Filtering: Only the specified imports (or all if none specified) are included
  6. Type Aliasing: Imported types and directives are renamed according to aliases
  7. Schema Merging: Imported types are merged into your schema
  8. Conflict Detection: Ensures no naming conflicts occur

This process supports recursive linking with circular reference protection and maintains type safety throughout the composition process.

Schema Loaders

Tanka GraphQL includes built-in schema loaders:

FederationSchemaLoader

Handles Apollo Federation specifications:

// Automatically configured when using Federation
options.SchemaLoader = new FederationSchemaLoader();

HttpSchemaLoader

Loads schemas from HTTP endpoints:

options.SchemaLoader = new HttpSchemaLoader();

CompositeSchemaLoader

Chains multiple loaders:

options.SchemaLoader = new CompositeSchemaLoader(
    new FederationSchemaLoader(),
    new HttpSchemaLoader()
);

Examples

Importing Federation Types

[Fact]
public async Task ImportingCustomTypes()
{
    var schema = """
        extend schema @link(
          url: "https://mycompany.com/schemas/validation/v1.0",
          import: ["@length", "@range", "@email", "@unique"]
        )

        type User {
          id: ID!
          email: String @email @unique
          name: String @length(min: 2, max: 50)
          age: Int @range(min: 13, max: 120)
        }

        type Query {
            user(id: ID!): User
        }
        """;

    var builtSchema = await new SchemaBuilder()
        .Add(schema)
        .Build(options =>
        {
            options.SchemaLoader = new ValidationSchemaLoader();
        });

    Assert.NotNull(builtSchema);
    Assert.NotNull(builtSchema.GetDirectiveType("length"));
    Assert.NotNull(builtSchema.GetDirectiveType("range"));
    Assert.NotNull(builtSchema.GetDirectiveType("email"));
    Assert.NotNull(builtSchema.GetDirectiveType("unique"));
}

Using Namespaces

[Fact]
public async Task UsingNamespaces()
{
    var schema = """
        extend schema @link(
          url: "https://mycompany.com/schemas/auth/v1.0",
          as: "auth"
        )

        type Query {
          profile: User @auth__authenticated
          admin: AdminPanel @auth__authorized(role: auth__ADMIN)
        }

        type User {
            id: ID!
            name: String
        }

        type AdminPanel {
            settings: String
        }
        """;

    var builtSchema = await new SchemaBuilder()
        .Add(schema)
        .Build(options =>
        {
            options.SchemaLoader = new CustomSchemaLoader();
        });

    Assert.NotNull(builtSchema);
    Assert.NotNull(builtSchema.GetNamedType("User"));
    // Note: Namespace prefixing implementation may need verification
}

Custom Specifications

You can import from your own specifications:

[Fact]
public async Task CustomSpecifications()
{
    var schema = """
        extend schema @link(
          url: "https://mycompany.com/schemas/auth/v1.0",
          import: ["@authenticated", "@authorized", "Role"]
        )

        type Query {
          profile: User @authenticated
          admin: AdminPanel @authorized(role: ADMIN)
        }

        type User {
            id: ID!
            name: String
        }

        type AdminPanel {
            settings: String
        }

        enum Role {
            USER
            ADMIN
        }
        """;

    // Note: This would require a custom schema loader for the auth specification
    var builtSchema = await new SchemaBuilder()
        .Add(schema)
        .Build();

    Assert.NotNull(builtSchema);
    Assert.NotNull(builtSchema.GetNamedType("User"));
    Assert.NotNull(builtSchema.GetNamedType("AdminPanel"));
}

Creating Custom Schema Loaders

Implement ISchemaLoader to load schemas from custom sources:

[Fact]
public void CustomSchemaLoaderExample()
{
    // Example of implementing a custom schema loader
    var customLoader = new CustomSchemaLoader();

    var options = new SchemaBuildOptions();
    options.SchemaLoader = new CompositeSchemaLoader(
        customLoader,
        new HttpSchemaLoader()
    );

    // Verify the loader chain is set up correctly
    Assert.NotNull(options.SchemaLoader);
    Assert.IsType<CompositeSchemaLoader>(options.SchemaLoader);
}

Processing Pipeline

The @link directive is processed by the LinkProcessingMiddleware during schema building:

  1. Initialization Stage: Basic schema setup
  2. Type Collection: Gather type definitions
  3. Link Processing Stage:
    • Process all @link directives
    • Load external schemas
    • Apply import filtering
    • Handle type aliasing
    • Merge imported types
  4. Type Resolution: Configure resolvers
  5. Validation: Validate complete schema
  6. Finalization: Create executable schema

Best Practices

  1. Be Specific with Imports: Only import what you need to avoid namespace pollution

    [Fact] public async Task SpecificImportsBestPractice() { // Best practice: Be specific with imports to avoid namespace pollution var schema = """ extend schema @link( url: "https://mycompany.com/schemas/auth/v1.0", import: ["@authenticated", "@authorized"] # Only import what you need )

         type Query {
             profile: User @authenticated
             admin: AdminPanel @authorized(role: ADMIN)
         }
    
         type User {
             id: ID!
             name: String
         }
    
         type AdminPanel {
             settings: String
         }
    
         enum Role {
             USER
             ADMIN
         }
         """;
    
     var builtSchema = await new SchemaBuilder()
         .Add(schema)
         .Build(options =>
         {
             options.SchemaLoader = new CustomSchemaLoader();
         });
    
     Assert.NotNull(builtSchema);
     Assert.NotNull(builtSchema.GetDirectiveType("authenticated"));
     Assert.NotNull(builtSchema.GetDirectiveType("authorized"));
     // @notused should NOT be available since it wasn't imported (even though it exists in the loaded schema)
     Assert.Null(builtSchema.GetDirectiveType("notused"));
    

    }

  2. Use Aliasing for Conflicts: When importing from multiple sources, use aliases to avoid naming conflicts

  3. Version Your Specifications: Include version numbers in URLs for stability

    [Fact] public async Task VersionedSpecificationsBestPractice() { // Best practice: Include version numbers in URLs for stability var schema = """ extend schema @link( url: "https://mycompany.com/schemas/auth/v1.0", # Specific version import: ["@authenticated"] )

         type Query {
             profile: User @authenticated
         }
    
         type User {
             id: ID!
             name: String
         }
         """;
    
     var builtSchema = await new SchemaBuilder()
         .Add(schema)
         .Build(options =>
         {
             options.SchemaLoader = new CustomSchemaLoader();
         });
    
     Assert.NotNull(builtSchema);
     Assert.NotNull(builtSchema.GetDirectiveType("authenticated"));
    

    }

  4. Cache External Schemas: Implement caching in custom loaders for performance

  5. Validate Imports: Ensure imported types are used correctly in your schema

Troubleshooting

Common Issues

  • Schema not loading: Verify the URL is accessible and the schema loader is configured
  • Type conflicts: Use aliasing or namespaces to resolve naming conflicts
  • Missing imports: Check that all required types are included in the import list
  • Circular dependencies: Avoid schemas that link to each other recursively

Debugging

Enable debug logging to trace @link processing by configuring logging at the Debug level in your application.

Limitations

  • Recursive linking depth is limited to prevent infinite loops
  • Schema loaders must return valid GraphQL SDL
  • Imported types must not conflict with existing types unless aliasing is used

See Also

  • xref:types:03-middleware.md[Middleware Pipeline] - How @link processing fits in the build pipeline
  • xref:extensions:apollo-federation.md[Apollo Federation] - Using @link for federation
  • xref:types:01_1-builder.md[Schema Builder] - Configuring schema loaders