Multi-tenancy
Viaduct's multi-tenant architecture allows multiple teams to independently develop and deploy their own GraphQL schemas (called tenants) within a shared Viaduct infrastructure. Each tenant owns its schema definitions and resolvers while contributing to a unified central schema.
This guide explains how to use multiple build modules and configure tenants to serve portions of a central schema.
What is a Tenant?¶
A tenant is an isolated GraphQL subschema with its own:
- GraphQL SDL (schema) files defining types, queries, and mutations
- Kotlin resolvers implementing the business logic
- Independent deployment lifecycle
Multiple tenants are composed together at build time to form the central schema that your Viaduct application exposes.
Module Architecture¶
Viaduct organizes tenants into a hierarchical module structure. Understanding this hierarchy is essential for managing dependencies and schema composition.
Module Hierarchy Levels¶
- Module Roots: Top-level organizational units (e.g.,
data,entity,presentation) - Sub-modules: Groupings within a module root (e.g.,
entity/listingblock,entity/userblock) - Tenants: Individual GraphQL schemas (e.g.,
entity/listingblock/stays/listing)
Example Structure¶
modules/
├── entity/ # Module root
│ ├── common/ # Sub-module (shared types)
│ │ └── commontypes/ # Tenant
│ │ ├── schema/
│ │ └── src/main/kotlin/
│ ├── listingblock/ # Sub-module
│ │ └── stays/
│ │ └── listing/ # Tenant
│ │ ├── schema/
│ │ └── src/main/kotlin/
│ └── userblock/ # Sub-module
│ └── user/ # Tenant
│ ├── schema/
│ └── src/main/kotlin/
├── data/ # Module root
│ ├── payments/ # Tenant
│ └── orders/ # Tenant
└── presentation/ # Module root
└── checkout/ # Tenant
Module Dependencies¶
Modules form a dependency graph that determines schema composition order. Common patterns include:
When you build a tenant, Viaduct automatically includes all schemas from ancestor modules. For example:
- Building
presentation/checkoutincludes schemas from:presentation,data,entity, andentity/common - Building
entity/listingblock/stays/listingincludes schemas from:entity/listingblock,entity, andentity/common
This dependency resolution ensures that:
- Types defined in lower-level modules are available to higher-level modules
- Schema composition happens in the correct order
- Common types are shared across tenants
Tenant Directory Structure¶
Each tenant follows a standard layout:
modules/<module>/<tenant>/
├── build.gradle.kts # Build configuration
├── schema/
│ └── src/main/resources/graphql/
│ └── <domain>/<entity>/ # GraphQL schema files
│ ├── Type1.graphqls
│ ├── Type2.graphqls
│ └── queries/
│ └── AllType1Query.graphqls
└── src/main/kotlin/
└── com/yourcompany/viaduct/<tenant>/
├── loaders/ # Data loading logic
└── resolvers/ # Resolver implementations
Schema Files Location¶
Schema files must be placed in:
The hierarchical path typically mirrors your module structure:
schema/src/main/resources/graphql/entity/listingblock/stays/listing/
├── Listing.graphqls
├── StayDetails.graphqls
└── queries/
└── AllListingsQuery.graphqls
This naming convention:
- Ensures schema files are properly discovered during build
- Prevents naming conflicts between modules
- Organizes schemas by ownership and domain
Creating Multiple Tenants¶
When creating new tenants, follow the standard directory structure and conventions outlined above. For detailed guidance on implementing resolvers, schemas, and build configuration, see the Star Wars Tutorial, which provides comprehensive examples of:
- Query resolvers, node resolvers, and field resolvers
- Batch resolution patterns for efficient data loading
- GraphQL schema design and type extensions
- Build configuration with Gradle
Using Multiple Tenants to Serve a Central Schema¶
Schema Composition¶
At build time, Viaduct merges all tenant schemas into a single central schema. The composition process:
- Discovery: Scans all tenant schema directories
- Aggregation: Collects all
.graphqlsfiles per module - Merging: Combines types, respecting GraphQL type system rules
- Validation: Ensures the composed schema is valid
- Code Generation: Generates resolver base classes
Type Extensions Across Tenants¶
Tenants can extend types defined in other tenants using GraphQL's extend keyword:
type Listing @scope(to: ["default"]) {
id: ID!
title: String!
}
extend type Listing @scope(to: ["default"]) {
amenities: [Amenity!]!
hasWifi: Boolean!
}
After composition, the central schema contains:
type Listing @scope(to: ["default"]) {
id: ID!
title: String!
amenities: [Amenity!]!
hasWifi: Boolean!
}
Warning
When extending types across tenants, ensure the extending tenant depends on the module containing the original type definition.
Shared Types and Common Modules¶
For types used across multiple tenants, create a common module:
type Address @scope(to: ["default"]) {
street: String!
city: String!
country: String!
}
type Money @scope(to: ["default"]) {
amount: BigDecimal!
currency: String!
}
All tenants depending on entity/common can reference these types:
type User @scope(to: ["default"]) {
id: ID!
billingAddress: Address # References common type
}
Using Scopes for Schema Visibility¶
Scopes control which fields appear in different schemas. This allows serving multiple schemas from a single central definition.
Defining Scopes¶
Annotate types and fields with @scope directives:
type User @scope(to: ["public", "internal"]) {
id: ID!
name: String!
}
extend type User @scope(to: ["internal"]) {
email: String!
ipAddress: String!
internalNotes: String!
}
Multiple Schema IDs¶
Configure your Viaduct application to expose multiple schemas:
@Factory
class ViaductConfiguration {
@Bean
fun providesViaduct(): Viaduct {
return BasicViaductFactory.create(
schemaRegistrationInfo = SchemaRegistrationInfo(
scopes = listOf(
SchemaId.Scoped("public", setOf("public")).toSchemaScopeInfo(),
SchemaId.Scoped("internal", setOf("public", "internal")).toSchemaScopeInfo(),
)
),
tenantRegistrationInfo = TenantRegistrationInfo(
tenantPackagePrefix = "com.yourcompany.viaduct"
)
)
}
}
Runtime Schema Selection¶
Choose which schema to use per request:
suspend fun graphql(
@Body request: Map<String, Any>,
@Header("X-Schema") schemaHeader: String?
): HttpResponse<Map<String, Any>> {
val schemaId = when (schemaHeader) {
"internal" -> INTERNAL_SCHEMA_ID
else -> PUBLIC_SCHEMA_ID
}
val executionInput = createExecutionInput(request)
val result = viaduct.executeAsync(executionInput, schemaId).await()
return HttpResponse.ok(result.toSpecification())
}
Requests using the "public" schema only see:
Requests using the "internal" schema see all fields:
Use Cases for Multiple Schemas¶
External vs Internal APIs
type Product @scope(to: ["public", "internal"]) {
id: ID!
name: String!
price: Money!
}
extend type Product @scope(to: ["internal"]) {
costBasis: Money!
profitMargin: Float!
inventoryCount: Int!
}
Feature Flags and Gradual Rollout
type Feature @scope(to: ["default", "beta"]) {
id: ID!
name: String!
}
extend type Feature @scope(to: ["beta"]) {
experimentalSettings: ExperimentalSettings!
}
Multi-Tenant SaaS Applications
type Dashboard @scope(to: ["enterprise", "pro", "free"]) {
basicMetrics: [Metric!]!
}
extend type Dashboard @scope(to: ["enterprise", "pro"]) {
advancedAnalytics: Analytics!
}
extend type Dashboard @scope(to: ["enterprise"]) {
customReports: [Report!]!
apiAccess: APICredentials!
}
Practical Example: Multi-Tenant E-Commerce¶
Let's build a multi-tenant schema for an e-commerce platform with separate teams owning different domains.
Module Structure¶
modules/
├── entity/
│ ├── common/ # Shared types
│ ├── productblock/ # Product catalog team
│ ├── userblock/ # User management team
│ └── orderblock/ # Order processing team
├── data/ # Data access layer
│ ├── payments/ # Payment service integration
│ └── inventory/ # Inventory service integration
└── presentation/ # API facade
└── storefront/ # Customer-facing API
Common Types (Entity Layer)¶
type Money @scope(to: ["public", "internal"]) {
amount: BigDecimal!
currency: String!
}
type Address @scope(to: ["public", "internal"]) {
street: String!
city: String!
postalCode: String!
country: String!
}
Product Tenant¶
type Product @scope(to: ["public", "internal"]) {
id: ID!
name: String!
description: String!
price: Money!
images: [String!]!
}
extend type Product @scope(to: ["internal"]) {
costBasis: Money!
supplierInfo: String!
profitMargin: Float!
}
type Query @scope(to: ["public", "internal"]) {
product(id: ID!): Product
searchProducts(query: String!): [Product!]!
}
User Tenant¶
type User @scope(to: ["public", "internal"]) {
id: ID!
name: String!
}
extend type User @scope(to: ["internal"]) {
email: String!
registeredAt: DateTime!
lastLogin: DateTime
}
Order Tenant¶
type Order @scope(to: ["public", "internal"]) {
id: ID!
orderNumber: String!
customer: User!
items: [OrderItem!]!
total: Money!
status: OrderStatus!
}
type OrderItem @scope(to: ["public", "internal"]) {
product: Product!
quantity: Int!
price: Money!
}
enum OrderStatus @scope(to: ["public", "internal"]) {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
extend type User @scope(to: ["public", "internal"]) {
orders: [Order!]!
}
type Query @scope(to: ["public", "internal"]) {
order(id: ID!): Order
}
type Mutation @scope(to: ["public"]) {
createOrder(input: CreateOrderInput!): Order!
cancelOrder(orderId: ID!): Order!
}
Presentation Layer¶
The presentation layer can aggregate and reshape data from entity and data layers:
type StorefrontData @scope(to: ["public"]) {
featuredProducts: [Product!]!
categories: [Category!]!
userRecommendations(userId: ID!): [Product!]!
}
extend type Query @scope(to: ["public"]) {
storefront: StorefrontData!
}
Composed Central Schema¶
After composition, the central schema for the "public" scope includes:
- All types from all tenants with
@scope(to: ["public"]) - Type extensions merged into base types
- Fields from the
Ordertenant extending theUsertype
Clients querying the public API can traverse the entire graph:
query CustomerOrders {
user(id: "123") {
name
orders {
orderNumber
total {
amount
currency
}
items {
product {
name
price {
amount
}
}
quantity
}
}
}
}
Best Practices¶
Module Organization¶
- Domain-Driven Design: Organize tenants by business domain, not technical layers
- Shared Types: Place common types in
entity/commonor similar base modules - Minimize Dependencies: Only depend on modules you actually need
- Clear Ownership: Each tenant should have a single owning team
Schema Design¶
- Use Type Extensions: Extend types across tenant boundaries rather than duplicating
- Scope Consistently: Apply scopes to all types—there is no default scope
- Hierarchical Naming: Use paths that reflect module hierarchy in schema file locations
- Avoid Circular Dependencies: Structure modules as a directed acyclic graph (DAG)
Resolvers¶
- Package Conventions: Follow consistent package naming across tenants
- Thin Resolvers: Delegate business logic to service/loader classes
- Batch Loading: Use data loaders to avoid N+1 queries
- Error Handling: Use consistent error patterns across tenants
Testing¶
- Per-Tenant Tests: Write unit tests for each tenant's resolvers
- Integration Tests: Test schema composition and cross-tenant queries
- Scope Validation: Verify fields appear only in intended schemas
Troubleshooting¶
Schema Composition Errors¶
Problem: Build fails with "Unable to find concrete type for interface"
Solution: The interface is defined in module A, but the implementing type is in module B, and B doesn't depend on A. Either move the interface to a common ancestor module or add a dependency from B to A.
Problem: Type extensions don't appear in the schema
Solution: Ensure the extending tenant's module depends on the module containing the base type.
Scope Issues¶
Problem: Fields are missing from the schema
Solution: Check that types and fields have the correct @scope annotations. Remember that fields in type extensions must use scopes that are subsets of the base type's scopes.
Problem: "Invalid scope usage within a type" error
Solution: Type extensions can only use scopes that were declared on the base type definition. Add the scope to the base type's @scope directive.
Runtime Errors¶
Problem: Resolver not found at runtime
Solution: Ensure:
- Resolver class is annotated with
@Resolver - Resolver package is under the configured
tenantPackagePrefix - The tenant's
build.gradle.ktsincludes the Viaduct tenant plugin
See Also¶
- Developers: Scopes — Detailed scope usage and validation rules
- Developers: Resolvers — Writing resolver implementations
- Getting Started: Tour — Understanding Viaduct project structure
- Schema Extensions — Application-wide custom directives and types