GraphQL in the sample
How the sample composes its three module schemas into one, what a query costs, and the rules each module schema follows. Read it before you give a module a schema. GraphQL describes the toolkit's side.
One schema over three modules
/graphqlserves the routes' commands and queries as one schema. The host composes it in the process from the schema of each module, withDTS.DDD.HotChocolate.Fusion.InMemory. Each feature has aGraphQLfolder beside itsRestfolder.- A module refers to another module's entity by id only. The gateway asks the owning module for the other fields. So Projects returns no names from Tenancy, and a client still gets a crew with names in one request.
The diagram shows which module owns which types. Projects and Inspections return ids to the gateway, and Tenants returns the names. Purple: the gateway; dashed arrows: what each module returns.
Projects declares a seat as its key alone: [GraphQLName("Seat")] [EntityKey("id")] record ReferencedSeat(SeatId Id).
Tenants declares the owned Seat type over its Seat class, with an internal lookup ([Lookup] [Internal]). The
lookup uses a data loader that sends SeatsById once per batch, in parts of at most 200 ids
(DirectoryQueries.cs).
Tenancy's classes as GraphQL types
Tenancy's managers return the application's classes: Tenant, Seat, Role, OrganizationUnit and
Invitation. The Tenants schema declares one GraphQL type over each class. For example, seats returns the Seat
objects that SeatManager.ListAsync read, and the Seat type shows their id, displayName and status.
- A type class lists the fields it shows. Each type class calls
BindFieldsExplicitly()and names the fields of the class one by one. A field that is not listed is not in the schema. The classes hold more than the schema shows:- A seat holds the identity of its person.
- An invitation holds the id of the account that the identity provider made for its address.
- A role holds the permissions that its starter role gave it.
- A field that the class lacks is a resolver in the type class. A unit's path depends on the units above it, so
OrganizationUnit.pathanddepthare resolvers. Whether a role manages access comes from the catalogue, soRole.managesAccessis a resolver. Each resolver loads its value through a data loader. The data loader sends a query, and the query's handler asks the manager. A resolver takes no manager itself. So the units of a list are one question to the manager, and that question passes the access check of its query. - A manager's record is a GraphQL type too. Some reads return more than one class holds. The manager then returns
a record with the class inside it, and the schema shows that record.
overviewOfMinereturns the seat manager'sSeatOverview, which holds the tenant, the seat, its units and its roles. The operators'tenantsreturns the tenant manager's page, where eachTenantListingholds a tenant beside its name and its number of active seats.
The diagram follows the fields of one unit. Purple: the resolvers in the type class; green: the manager that reads the path and the depth; grey: the fields that come from the object, with no read.
Tenants.Api/Organization/GraphQL/OrganizationUnitType.cs: the type over the unit, with the two fields the unit
lacks.
[ObjectType<OrganizationUnit>]
internal static partial class OrganizationUnitType
{
static partial void Configure(IObjectTypeDescriptor<OrganizationUnit> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(unit => unit.Id);
descriptor.Field(unit => unit.ParentId);
descriptor.Field(unit => unit.Name);
descriptor.Field(unit => unit.Kind);
descriptor.Field(unit => unit.Status);
}
[Cost(DirectoryQueries.LoadedForTheRequest)]
public static async Task<string?> GetPathAsync(
[Parent] OrganizationUnit unit, IOrganizationUnitNameByIdDataLoader names, CancellationToken cancellationToken)
=> (await names.LoadAsync(unit.Id, cancellationToken))?.Path;
[Cost(DirectoryQueries.LoadedForTheRequest)]
public static async Task<int?> GetDepthAsync(
[Parent] OrganizationUnit unit, IOrganizationUnitNameByIdDataLoader names, CancellationToken cancellationToken)
=> (await names.LoadAsync(unit.Id, cancellationToken))?.Depth;
}
[ObjectType<OrganizationUnit>]makes the classOrganizationUnita GraphQL type. HotChocolate's generator adds every public static method of the type class as a field.GetPathAsyncbecomespath.[Parent]is the unit that the field belongs to. The data loader collects the ids of a batch and sends oneOrganizationUnitsByIdfor them.[Cost]says that the field reads once per batch. Without it, HotChocolate estimates one read per unit.- The resolver runs only when a client asks for
path.{ organizationUnits { name kind } }reads the units once.
Show the code: the role, with a rule on one field and a resolver on another
Tenants.Api/Roles/GraphQL/RoleType.cs
[ObjectType<Role>]
[EntityKey("id")]
internal static partial class RoleType
{
static partial void Configure(IObjectTypeDescriptor<Role> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(role => role.Id);
descriptor.Field(role => role.Name);
descriptor.Field(role => role.FromStarterRole);
descriptor.Field(role => role.Status);
}
[Authorize(RoleListing.ManageRolesPermission)] // tenancy.roles.manage
public static IReadOnlyList<string> GetPermissions([Parent] Role role) => role.Permissions;
[Cost(DirectoryQueries.LoadedForTheRequest)]
public static async Task<bool?> GetManagesAccessAsync([Parent] Role role, IRoleByIdDataLoader roles, CancellationToken cancellationToken)
=> (await roles.LoadAsync(role.Id, cancellationToken))?.ManagesAccess;
}
The types of the Tenants schema, with the class or record each is declared over:
| GraphQL type | Declared over | Fields the class lacks |
|---|---|---|
Tenant | Tenant | slug is a resolver that returns the text of the TenantSlug. |
Seat | Seat | None. The type shows id, displayName and status. |
Role | Role | managesAccess loads through IRoleByIdDataLoader. permissions requires tenancy.roles.manage. |
OrganizationUnit | OrganizationUnit | path and depth load through IOrganizationUnitNameByIdDataLoader. |
Invitation | Invitation | unit, role and issuedBy load through the three directory loaders. |
SeatOverview | SeatManager.SeatOverview | placements returns the placements of the overview's seat. |
Placement, RoleGrant | the package's Placement<…> and RoleGrant<…> | None. They name units and roles by id. |
SeatOfMine | SeatInTenant<Seat> | None. |
TenantPage, TenantListing | TenantManager.TenantPage and TenantManager.TenantListing | None. |
OrganizationUnitName | NamedUnit<OrganizationUnitId> | None. |
IssuedInvitation | InvitationManager.IssuedInvitation | None. |
AcceptedInvitation | InvitationManager.AcceptedInvitation | None. |
The routes write the same fields as the types. GET /tenancy/units writes each unit with path and depth, from the
organization manager's UnitInTree. GET /me writes the fields of SeatOverview. Its placements name units and
roles by id, and the overview's units and roles say what each id is called. A route writes a seat as id,
displayName and status. It never writes the identity.
What a mutation returns
A command of the Tenants module returns the object that its manager returned. A command that creates returns the new object, and a command that changes returns the changed aggregate. The mutation sends the command and returns that object. It reads nothing again.
| Mutations | Return |
|---|---|
seatRename, seatSuspend, seatReactivate, seatDeactivate, seatPlace, placementWithdraw, roleGrant, roleRevoke | The Seat |
roleCreate, rolePermissionsSet, roleArchive | The Role |
organizationUnitAdd, organizationUnitMove, organizationUnitArchive | The OrganizationUnit |
tenantShapeChange | The Tenant, in its new shape |
personInvite | The IssuedInvitation: the invitation and its token |
invitationCancel | The cancelled Invitation |
invitationAccept | The AcceptedInvitation: the Tenant, whose slug the next request names, and the new Seat |
- The resolvers of a type still run for what the class lacks. A unit's
pathand a role'smanagesAccessload through their data loaders after the command has saved. - The route of the same command writes the same object, with
200, or201for a new unit or role. A route writes a unit from a command withoutpathanddepth, because the command reads nothing more. It writes a role from a command with itspermissions, because each of those commands requirestenancy.roles.manage. - A mutation of Projects sends the project's query after its command and returns the project's overview. The overview holds what the caller may do with the project, which the command does not read.
A field on another module's type
A module can add fields to a type that another module owns. In Inspections' schema, Project has the key and one
field, inspections. The gateway adds that field to the project that Projects returns. No root field takes a
project's id, so a client asks for the project:
{
projects {
nodes {
name
inspections(first: 5) { nodes { title } }
}
}
}
- For each batch of projects, Inspections makes one gate call and runs one SQL statement:
- A data loader sends one
InspectionsOfProjects. - The access check of that query calls Projects' gate once (
IProjectGate.AskAsync). - The read pages the inspections of every project in the batch at once (
ToBatchPageAsync).
- A data loader sends one
canRecordandtotalCountcost one more statement each, when the client asks for them.- A batch is what HotChocolate has collected when it sends. As a rule a batch is one page, or two pages on a busy server.
- A project that the caller may not see returns
null. Inspections' own check decides this, whatever id reaches it. GET /projects/{id}/inspectionspages the same way. So itsnextis the GraphQL cursor of the page's last row.
Show the code: the field added to another module's type
Inspections.Api/Recording/GraphQL/ProjectType.cs
// Project as Inspections declares it, over a record that holds the project's id
[ObjectType<ReferencedProject>]
[EntityKey("id")]
internal static partial class ProjectType
{
static partial void Configure(IObjectTypeDescriptor<ReferencedProject> descriptor) => descriptor.Name("Project");
[UseConnection(IncludeTotalCount = true, DefaultPageSize = DefaultPage, MaxPageSize = LargestPage)] // 10, and 50 at most
[Cost(InspectionsConnection.LoadedForThePage)] // 1: one read for a batch
public static async Task<InspectionsConnection?> GetInspectionsAsync(
[Parent] ReferencedProject project, PagingArguments paging, IInspectionsByProjectIdDataLoader inspections, CancellationToken cancellationToken)
=> await inspections.With(paging).LoadAsync(project.Id, cancellationToken) is { } page
? new InspectionsConnection(page, project.Id)
: null;
}
How one query runs
The diagram follows one query from the client to the modules. Each purple band is one call to a module. Each call starts with the seat gate, and loads its page or its batch of ids at once.
How a module declares its schema
The modules follow the way HotChocolate 16 offers, and write nothing twice. GraphQLDeclarationTests checks every
module against these rules. Projects' schema is the example:
ProjectType.cs,
OverviewPagedQueries.cs,
CrewMemberType.cs.
- A field contains no logic. It sends the request that the route of its feature sends. A mutation returns what it changed (What a mutation returns).
- A type is what a query returns. It is declared over that class or record with
[ObjectType<T>]. In Projects and Inspections that is the application's record. In Tenants it is one of Tenancy's classes or a manager's record, as Tenancy's classes as GraphQL types shows. Nothing is copied or mapped. - Missing data returns
nullwithout an error.project(id:)andnode(id:)returnnullfor a project that is missing or out of sight. A reference that its owner does not resolve, such as another tenant's seat, returns its id with every other fieldnull. So every field except the key ofSeat,OrganizationUnitName,RoleandProjectis nullable (AddDDDToolkitEntityNullability()). - Ids are loaded together, once per field of the response. For example, the owners of a page take one
SeatsById.crew,myRolesandcanare resolvers behind data loaders. Each runs one statement per batch, when the client asks for it. - A field can require a permission, visible in the schema, on top of the check of every request:
[Authorize("tenancy.roles.manage")]onRole.permissionsandProjectRole.permissions, and[Authorize("projects.crew.manage")]onCrewMember.roles. Without the permission the field isnull, with a coded error. - The routes keep the same rule. The crew queries check
projects.crew.managein their SQL statement and returnnullfor the roles. An empty list would mean "none", so they do not return one. The role queries return whether the caller holdstenancy.roles.managefor the whole tenant, andGET /tenancy/roleswrites"permissions": nullwhen it does not. A seat reads what it holds itself in its overview: its roles by name, and each permission with the units it reaches. - Tenants returns whole lists for
seats,organizationUnits,rolesandopenInvitations. The operators'tenantsfield keeps the package's page and cursor. - A history row has
byKind,bySeatIdandbySeat. - Invitations.
personInvitereturns the invitation and its token, once. Send the token as a variable. - Operators' fields (
tenants,tenantAccessHistory,tenantProjects,tenantProjectInspections) return ids where other fields return names. The directory resolves names only for a seat of the tenant, and an operator has none. - Errors are typed in the mutation's payload. A refusal is a
RefusalErrorwith its code. Project mutations takeexpectedVersion, which is the routes'If-Match, and return the newversion. A stale version returns aConcurrencyConflictError. - A project is a node. Its
idis a node id. Seats, units and roles keep plainUUIDs.
Access, language and the database
- A token, then a seat.
/graphqlrequires a token.SeatGateruns the routes' seat check (SeatRequirement.RefusalFor) in front of every root field. So a client getstenancy.tenant-requiredortenancy.not-seatedwith its code.seatsOfMineandinvitationAcceptneed no seat. The operators' fields need an operator (tenancy.operators-only). - The request's language. The gateway runs after
UseRequestLocalization(). So a refusal'smessagefollowsAccept-Language(A sample in two languages). - The caller in the database. A field only sends a request. So its reads run under the caller's database role and claims, and the row level security policies check them again.
- Only in REST: the dev login,
GET /access/projects/{id}?permission=, health, andPOST /tenancy/directory/*. In GraphQL, references resolve the names that the directory routes return.
Paging and cost limits
- Three connection types, over the queries that their routes send:
projectsreturns aProjectConnection.- A project's
inspectionsreturns anInspectionsConnection, withcanRecord. accessHistory, and the operators'tenantAccessHistory, return anAccessHistoryEntryConnection.
- Each connection has
nodes,edges,pageInfo,totalCountwhen asked, and the same cursors as the routes. Each refuses a cursor from another list. - One end per page.
firsttogether withlastis refused with the list's code (projects.page-from-both-ends, and the same code for the other lists). A page size outside the list's range is refused too. One helper does both:PageSizesinShared/Examples.Tenancy.Shared.Application. - Within the cost limit. HotChocolate estimates the cost of a request from the largest page of each list and a
weight per field. So each connection states its sizes: ten by default and fifty at most. The routes use fifty and
two hundred. A field behind a data loader weighs one (
[Cost(1)]), instead of ten per row. - The gateway limits the whole request. The cost estimate is per module schema, and a field that another module
adds runs once per row. So the gateway refuses a document with more than 200 fields or more than ten levels, before
it asks any module (
AddSampleRequestBoundsinHost/GraphQL/SampleGraphQL.cs). The deepest screen asks for seven levels. Introspection keeps HotChocolate's own limit, so GraphQL Codegen's introspection query still works. - A signed-in user's SQL statement stops after ten seconds (
StatementTimeouts[CurrentUserKind.User]inHost/Storage/SampleStorage.cs). - No subscriptions. A WebSocket carries no header per message. A host that adds subscriptions reads the tenant once, from the first message.
The administration gateway
/admin/graphql offers everything that /graphql offers, plus another person's roles: seatGrants(seatId:). The
field sends the query that GET /tenancy/seats/{seatId}/grants sends. It requires tenancy.seats.manage for the
whole tenant, which is the sample's choice.
SeatsAdminQueriesis marked[GraphQLSchema("admin", OperationType.Query)], so it is registered in that schema only.DirectoryQueriesis marked for both schemas.- Each schema is composed with Projects and Inspections in a gateway of its own
(Several gateways,
SampleGateways). The host maps the administration gateway behind the seat policy. - Maud reads Rhea's roles there. Leo gets
tenancy.not-permitted. Another tenant's seat returnstenancy.seat-not-found./graphqlrefuses the field when it validates the document, because its schema has no such field. - The schema decides which fields a caller is offered. The request's check decides who may read the answer.
- The operators' fields are in both schemas.
/admin/graphqladmits only callers with a seat, so there those fields returntenancy.operators-only.
Schema files and tool access
- A tool reads a schema with a key, because Codegen and Relay send no token. The host reads the key from
GraphQL:SchemaKey, and the tool sends it asX-GraphQL-Schema-Key. Development needs no key. The key reads the schema and nothing else (Reading the schema from a tool). - The schemas are committed.
schema.graphqlandadmin.graphqlare beside the host'sProgram.cs, and each module has itsGraphQL/schema.graphql.GraphQLSchemaTestscompares them with the running schemas. - With
TENANCY_WRITE_SCHEMA=1,GraphQLSchemaTestswrites the files again. That run fails and says so. The next run passes.