A tenant module is a unit of schema together with the code that implements that schema, owned by a single team. It’s the fundamental building block of a Viaduct application.
Think of a tenant module as a self-contained GraphQL microservice, but without the operational overhead. It contributes its portion of the schema to the central graph and provides resolvers for its fields.
interface TenantModule { /** Metadata to be associated with this module. */ val metadata: Map<String, String> /** The package name for the module */ val packageName: String get() = javaClass.`package`.name}
This interface is minimal by design - the real work happens through the generated code and resolver registration.
The bridge between the Tenant API and the Engine API is the TenantModuleBootstrapper:
interface TenantModuleBootstrapper { /** * Returns field coordinates mapped to their executor implementations */ fun fieldResolverExecutors( schema: ViaductSchema ): Iterable<Pair<Coordinate, FieldResolverExecutor>> /** * Returns node type names mapped to their executor implementations */ fun nodeResolverExecutors( schema: ViaductSchema ): Iterable<Pair<String, NodeResolverExecutor>>}
Viaduct’s code generator creates implementations of TenantModuleBootstrapper for each module. You don’t implement this interface manually - it’s generated based on your @Resolver annotations.
type Character implements Node @scope(to: ["default"]) @resolver { id: ID! name: String birthYear: String height: Int mass: Float # Computed fields isAdult: Boolean @resolver displayName: String @resolver filmCount: Int @resolver # Relationships homeworld: Planet @resolver species: Species @resolver}extend type Query @scope(to: ["default"]) { allCharacters(limit: Int): [Character] @resolver searchCharacter(search: CharacterSearchInput!): Character @resolver}
Notice how the Character type references Planet and Species from the universe module. Modules can reference types from their dependencies without code coupling.
Modules can depend on other modules to access their types:
build.gradle.kts
dependencies { // Depend on common module for shared types implementation(project(":modules:common")) // Depend on universe module to reference Planet, Species implementation(project(":modules:universe"))}
At build time, Viaduct composes all module schemas into the central schema:
1
Collect Schemas
Gather all .graphqls files from all modules in dependency order
2
Merge Type Definitions
Combine base type definitions and extensions using GraphQL’s extend keyword:
# From universe moduletype Character { id: ID!, name: String }# From filmography moduleextend type Character { displayName: String }# Result in central schematype Character { id: ID!, name: String, displayName: String }
3
Validate Schema
Ensure the composed schema is valid according to GraphQL specification
4
Generate Code
Create GRTs and resolver base classes from the central schema
The Messaging team extends User without depending on Core User’s code:
// Messaging module resolver@Resolver(""" fragment _ on User { firstName lastName }""")class DisplayNameResolver : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String { val first = ctx.objectValue.getFirstName() val last = ctx.objectValue.getLastName() return "$first ${last.first()}." }}
The engine:
Sees the fragment requesting firstName and lastName
Batches all requests for those fields
Invokes the Core User module’s resolver for those fields
Makes the results available to the Messaging resolver via ctx.objectValue
This fragment-based composition is called re-entrancy - one module’s logic composes with another’s by issuing GraphQL fragments. See Re-entrancy for details.
✓ Keep modules focused on a single domain✓ Use @Resolver fragments to access other modules' data✓ Extend types from other modules when appropriate✓ Document which team owns each module✓ Version your schema changes carefully