Skip to content

Scope

The @scope directive is part of Viaduct’s security and multi-tenancy model. It restricts which GraphQL types, fields, or directives are visible and executable to a given request, depending on which scoped schema your application selects for that request.

Purpose and context

@scope enforces visibility boundaries within a schema. A single service can present different data surfaces for different modules, users, or environments — all from the same runtime. It is central to the security model of Viaduct, ensuring that clients only see and execute what their scopes permit.

Typical use cases:

  • Expose different schemas or fields to separate modules.
  • Hide beta or experimental fields behind an “extras” scope.
  • Limit sensitive data (like internal notes or metadata) to privileged scopes.
  • Support gradual rollout of features per environment or user segment.

Code definitions

extend type Query @scope(to: ["default"]) {
  """
  Search for a character using exactly one search criteria
  """
  searchCharacter(
    """
    Search input - exactly one field must be provided
    """
    search: CharacterSearchInput!
  ): Character @resolver

View full file on GitHub

extend type Species @scope(to: ["extras"]) {
  culturalNotes: String @resolver
  rarityLevel: String @resolver
  specialAbilities: [String] @resolver
  technologicalLevel: String @resolver
}

View full file on GitHub

In this example, searchCharacter is in every scoped schema that includes the default scope. The culturalNotes is defined in extras.

This demo app hides extras from requests to /graphql and serves it on /extras/graphql.

How scopes are evaluated

A request does not carry scopes into Viaduct. It selects a registered scoped schema by its SchemaId, and that schema contains only the fields visible to its scopes. Your application decides which scoped schema each request gets.

In the Star Wars demo, the route decides. Each declared scoped schema is served on its own path:

  • POST /graphql runs against publicSchema (scope default).
  • POST /extras/graphql runs against publicSchemaWithExtras (scopes default and extras).
  • A field is in a scoped schema if any of the schema's scopes appears in the field's to: list. Fields outside it cannot be queried and are omitted from introspection.

Because each route serves one fixed schema, introspection on a route describes exactly what that route executes. The demo leaves both routes open to every caller; a real service authorizes callers per route.

How the Star Wars demo routes requests to a scoped schema

ViaductRestController declares one route per scoped schema, and each passes its schema ID to a shared handler:

@Post("/graphql")
suspend fun graphql(
    @Body request: Map<String, Any>,
    @Header("security-access") securityAccess: String?
): HttpResponse<Map<String, Any?>> = execute(request, schemas.default.schemaId, securityAccess)

@Post("/extras/graphql")
suspend fun extrasGraphql(
    @Body request: Map<String, Any>,
    @Header("security-access") securityAccess: String?
): HttpResponse<Map<String, Any?>> = execute(request, schemas.extras.schemaId, securityAccess)

View full file on GitHub

@Post("/graphql")
public CompletableFuture<HttpResponse<Map<String, Object>>> graphql(
    @Body Map<String, Object> request,
    @Header("security-access") @Nullable String securityAccess) {
  return execute(request, schemas.defaultSchema().getSchemaId(), securityAccess);
}

@Post("/extras/graphql")
public CompletableFuture<HttpResponse<Map<String, Object>>> extrasGraphql(
    @Body Map<String, Object> request,
    @Header("security-access") @Nullable String securityAccess) {
  return execute(request, schemas.extras().getSchemaId(), securityAccess);
}

View full file on GitHub

Those schemas are declared in src/main/viaduct/scopes.yaml:

version: 1

schemaScopes:
  - default
  - extras

scopedSchemas:
  - id: publicSchema
    scopes: [default]
  - id: publicSchemaWithExtras
    scopes: [default, extras]

View full file on GitHub

ViaductConfiguration.kt (Kotlin) or ViaductConfiguration.java (Java) reads them back by id, so the controller never restates their scopes:

@Singleton
fun providesStarWarsSchemas(): StarWarsSchemas =
    StarWarsSchemas(
        default = SchemaScopeInfo.fromResources(setOf("publicSchema")).single(),
        extras = SchemaScopeInfo.fromResources(setOf("publicSchemaWithExtras")).single(),
    )

View full file on GitHub

@Singleton
public StarWarsSchemas providesStarWarsSchemas() {
  return new StarWarsSchemas(
      SchemaScopeInfo.fromResources(Set.of("publicSchema")).get(0),
      SchemaScopeInfo.fromResources(Set.of("publicSchemaWithExtras")).get(0));
}

View full file on GitHub

See Scope Schemas for the syntax of scopes.yaml.

With the correct schema, the controller builds the ExecutionInput for Viaduct

private fun createExecutionInput(
    query: String,
    request: Map<String, Any>
): ExecutionInput {
    @Suppress("UNCHECKED_CAST")
    return ExecutionInput.create(
        operationText = query,
        variables = (request[VARIABLES_FIELD] as? Map<String, Any>) ?: emptyMap(),
        requestContext = emptyMap<String, Any>(),
    )
}

View full file on GitHub

ExecutionInput executionInput =
    ExecutionInput.builder()
        .operationText(query)
        .variables(readVariables(request))
        .requestContext(
            Map.of(
                SecurityAccessContext.REQUEST_CONTEXT_KEY,
                new SecurityAccessContext(securityAccess)))
        .build();

View full file on GitHub

And runs the query, this is the entire logic of the controller:

val result = viaduct.execute(executionInput, schemaId)
return HttpResponse.ok(result.toSpecification())

View full file on GitHub

return viaduct
    .executeAsync(executionInput, schemaId)
    .thenApply(result -> HttpResponse.ok(result.toSpecification()));

View full file on GitHub

Demo example (Star Wars)

Route (demo) Schema ID Accessible types/fields
/graphql publicSchema All default types and fields.
/extras/graphql publicSchemaWithExtras Default plus extras types and fields.

Multi-tenancy and security boundaries

@scope provides a soft isolation layer between modules or schema segments. Unlike role-based access control, it operates when each scoped schema is built: elements outside its scopes are absent from it, so they are never planned or run.

In the Star Wars demo:

  • The default slice exposes public entities like Character, Planet, and Film.
  • The extras slice adds extended metadata (for example, Species.culturalNotes) for internal users.
  • Both slices share infrastructure and resolvers while seeing different schema surfaces.

Best practices

  • Define a default scope for general availability.
  • Keep scopes orthogonal — avoid overlapping responsibilities.
  • Apply @scope explicitly to sensitive fields.
  • Decide from a single source of truth (JWT claims, session, config) which callers may reach each scoped schema's route.
  • Log the route and schemaId each request runs against.
  • Define other scopes like internal, admin, or beta as needed, and limit the usage as described above.

See Best Practices for the consolidated reference.

Common mistakes

1. Selecting the scoped schema with a request header

A header makes one URL answer with several schemas, so a tool that introspects the URL sees only one of them. Serve each scoped schema on its own route instead.

2. Missing default scope

If no scope is declared where you expected one, the field may be invisible to all requests.

3. Overlapping scopes without clear ownership

Prefer clear, non-overlapping boundaries to reduce confusion and accidental exposure.

4. Relying solely on @scope for authorization

@scope governs schema visibility. Complement it with application-level authorization for data-level controls.

Query example

culturalNotes and specialAbilities are only visible on /extras/graphql:

query {
  node(id: "U3BlY2llczox") {
    ... on Species {
      name
      culturalNotes
      specialAbilities
    }
  }
}

The demo's GraphiQL page sends queries to /graphql, so run this one against the extras route directly:

curl -s http://localhost:8080/extras/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ node(id: \"U3BlY2llczox\") { ... on Species { name culturalNotes specialAbilities } } }"}'

Debugging and testing

  • In the demo, send the same query to /graphql and /extras/graphql to confirm visibility.
  • Verify that restricted fields disappear from both introspection and responses.
  • Add integration tests per scope slice (Kotlin: ExtrasScopeTest.kt; Java: StarWarsJavaIntegrationTest.honorsExtrasScope()).

For the full @scope API and multi-tenancy patterns, see the Scopes developer reference. For declaring scoped schemas in scopes.yaml, see Scope Schemas.