@asConnection turns a list field into a Relay connection: the schema-transform stage rewrites [T!]! to a synthesised TConnection, the generator emits keyset SQL, and the runtime returns edges, nodes, pageInfo, and (when asked for) totalCount. The reference page covers signature, examples, and constraints. This recipe addresses three operational questions that bite teams in review: why each connection field should own its own Connection type, what totalCount actually costs (and when it returns null), and what the rewrite does about page-size caps.
For the cursor-stability and tie-breaker side of pagination, see How-to: Sort results. The sort spec and the connection are separate concerns: the order is what the cursor encodes, and the connection is what the cursor traverses.
Synthesised vs declared connection types
Two paths produce a connection type, and the rewrite recognises both:
-
Directive-driven (synthesised).
myField: [T!]! @asConnectionon a bare list. The generator manufactures<ParentType><FieldName>Connectionand a matchingEdgetype, registers them on the schema, and rewrites the field’s return type to point at the synthesised name.Query.stores: [Store!]! @asConnectionbecomesQuery.stores: QueryStoresConnection!. -
Structural (declared).
myField: FilmsConnection!where the SDL already declaresFilmsConnection { edges: [FilmsEdge!]!, nodes: [Film!]!, pageInfo: PageInfo!, totalCount: Int }. The rewrite recognises theedges.nodeshape and reuses the SDL type as-is.@asConnectionmay also be present on the carrier, but the directive’s job is just to opt the field into the connection emitter; no rewrite of the return type happens.
The Sakila example schema uses both shapes intentionally: Query.stores exercises the synthesised path (QueryStoresConnection), and Query.filmsConnection: FilmsConnection! exercises the declared path. Both compile to the same kind of fetcher; the difference is purely which side owns the type’s name.
One field, one connection type
Each @asConnection field gets its own synthesised Connection type, named <ParentType><FieldName>Connection. The default is the recommended outcome: a field-specific type for a field-specific result set. The connectionName: argument that overrides the default is deprecated; reach for it only when migrating a legacy schema.
The reason is semantic, not syntactic. Two fields that paginate the same element type usually do not return the same logical result:
-
Different parents.
Query.customersreturns the global customer list;Store.customersreturns one store’s customers. The element type isCustomerin both cases, but the result sets are scoped differently. A sharedCustomersConnectionwould lie about that scoping at the type level: a tool inspecting the schema would see one connection and assume one filter contract, when the runtime contracts differ. -
Different filters and arguments. A connection field’s argument set is part of its identity.
Query.activeCustomers(active: Boolean = true)andQuery.customersproduce different sub-populations of the same table; a shared Connection type would erase that distinction. -
Different orders, different cursors. The cursor wire format embeds the active sort columns. Two fields that have different
@defaultOrder(or different@orderByenum spaces) cannot share cursors, even when they share an element type. Sharing the Connection type but not the cursor format produces a leaky abstraction at the schema boundary. -
Independent evolution. Two fields that share a type evolve in lockstep: adding a column to one’s order spec, adding a filter to one’s element shape, or renaming one’s edge metadata forces the other to follow. Field-owned Connections decouple that lifecycle.
The synthesised default <ParentType><FieldName>Connection honours all four points by construction. Query.customers becomes QueryCustomersConnection and Store.customers becomes StoreCustomersConnection; they’re separate types in the schema because they’re separate things in the domain. Clients that need to pass both lists through the same UI component still can — the GraphQL types are independent, but the underlying Customer element shape is shared.
The deprecated connectionName: override coalesces multiple carriers onto one type. It exists for one purpose: migrating a legacy schema where the wire-side name is already published and a rename would break clients. New schemas should not use it; legacy schemas should plan a transition off it. The dedup is also silent — two carriers with the same name but different element types reuse the first registration without validation, producing a connection whose edges.node doesn’t match one of the carriers — which is a sharp edge worth avoiding for that reason alone.
totalCount is lazy and orthogonal
totalCount: Int is optional on every connection (synthesised or declared). The reference page documents the field; the operational facts are:
-
Lazy on selection. graphql-java only invokes the registered
totalCountresolver when the client selects the field. Queries that ask foredges,nodes, orpageInfoand skiptotalCountpay zero count cost. This is whytotalCountisInt(nullable), notInt!: the resolver can decline to produce a value, and the field’s nullability lets the caller observe that. -
Issued as a separate
SELECT count().* The page query and the count query are independent SQL passes. The count uses the sameTable<?>andConditionthe page used (carried on theConnectionResultfor root connection fetchers), so the count matches the page’s predicate; it does not duplicate the page’s projection orseek/limit. -
Returns
nullon split-connection paths. Per-parent paginated children built with@asConnection+@splitQuery(e.g.Film.actorsConnection) supply aConnectionResultwithout(table, condition): the scatter pass partitions one wide query across many parents, so there is no single per-parent(table, condition)pair to count against. SelectingtotalCounton a split-connection carrier returnsnulluntil the per-parent count plumbing lands. This is observable: clients asking fortotalCountonFilm.actorsConnectionseenull, not the per-film actor count. -
Returns
nullon multi-table polymorphic connections.Query.searchConnectionand friends emit a per-branchUNION ALLwrapped in a derived table. The polymorphic count query is a separate roadmap follow-up; until it lands,totalCounton these carriers also returnsnull.
The performance consequence: keep totalCount available in the schema for clients that need it, but do not assume it costs nothing per query. A client that always asks for totalCount doubles the round trip count; for high-RPS reads, prefer not selecting it.
Page size: defaultFirstValue and the missing cap
Page size resolves at request time:
-
Client supplies
first: N. UseN. No cap. -
Client omits
first:. Use the directive’sdefaultFirstValue:(the@asConnection(defaultFirstValue: 5)argument), or the generator’sDEFAULT_PAGE_SIZE = 100whendefaultFirstValue:is also omitted. -
Backward pagination (
last: N). Same shape,lastsubstitutes forfirstin the resolution.
There is no server-side cap on first: or last: in the rewrite. The legacy generator’s <maxAllowedPageSize> Maven-plugin setting was deliberately excised from the rewrite plugin (the rewrite Mojo’s five @Parameter fields are schemaInputs, outputDirectory, outputPackage, jooqPackage, namedReferences; no page-size cap). A client passing first: 100000 will produce that many rows, modulo database memory and network limits.
If you need a request-rate or page-size cap, enforce it outside the generator:
-
Apollo persisted queries or another query-allowlist that constrains accepted shapes.
-
Servlet-layer limits (request size, response size, query timeout) configured on the host application.
-
graphql-java instrumentation that rejects queries whose
first:/last:exceed a threshold before the field resolves.
The defaultFirstValue: argument is purely a default for the omitted-first: case, not a cap. Setting @asConnection(defaultFirstValue: 5) on Query.searchConnection (as the example schema does) means clients that don’t ask for a specific page size get five rows; clients that ask for first: 1000 still get up to 1000.
Cursor encoding is part of the public contract
Cursors are base64-encoded NUL-separated tuples of the active sort columns' values for the last row of the page. Two consequences worth restating here (the sort recipe covers the full set):
-
The cursor’s columns are the order’s columns. Adding, removing, or reordering columns in
@defaultOrderor in an@orderenum value invalidates outstanding cursors. Treat the order column list as part of the cursor wire contract. -
Decoding rejects shape mismatches. When the request’s effective order produces a different column count than the cursor decodes, the request fails. A cursor issued under
@orderBycannot be replayed against a@defaultOrder-only request unless the column lists happen to coincide.
Backward pagination reuses the same encoding; the rewrite reverses each SortField’s direction via jOOQ’s `$field()/$sortOrder() model API, runs the seek, and reverses the in-memory result before returning. A cursor issued from a first: page round-trips through a before: request and vice versa.
Composition with @splitQuery
@asConnection + @splitQuery is the right pattern for paginated child lists (per-parent slices). The combination produces a ROW_NUMBER() OVER (PARTITION BY parentInput.idx ORDER BY …) envelope so each parent’s connection page is the leading N+1 rows of its partition; a DataLoader batches the dispatch across parents.
type Film @table(name: "film") {
actorsConnection(
first: Int, last: Int, after: String, before: String
): ActorsConnection! @splitQuery @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
]) @defaultOrder(primaryKey: true)
}
Without @splitQuery, the same [Actor!]! child list under @asConnection paginates across all parents in a single SQL pass: there is no PARTITION BY envelope, and the first: N argument applies to the union of all parents' rows. That is rarely the right behavior at the child level; reach for @splitQuery whenever the parent appears in a list.
The totalCount consequence cited above applies here: split-connection carriers return null for totalCount until per-parent count plumbing lands.
Constraints and pitfalls
-
@asConnectionapplies to list-typed fields. Single-cardinality@asConnectionis rejected at classify time. -
The field must have a stable order. Either
@defaultOrderis set, or@orderByis required at runtime; without one, keyset pagination has no anchor and the build fails. -
connectionName:is deprecated. Each connection field should own its own Connection type; the override exists as a transition mechanism for legacy schemas. When set, the dedup is silent — two carriers with the same name and different element types reuse the first registration and only the first carrier’s element type appears under that connection’sedges.node. -
totalCountisnullon split-connection and multi-table polymorphic carriers. Document this for clients; do not surface0as a substitute. -
totalCountis lazy on selection but a separate SQL pass when selected. Treat it as observable extra cost; don’t assume it’s free. -
No server-side page-size cap.
defaultFirstValue:is the omitted-first:default, not a maximum. Enforce caps at the API layer if needed. -
Cursor wire format embeds the active order’s columns. Sort-spec changes invalidate outstanding cursors; treat the column list as part of the public contract.
-
@asConnection+@splitQueryis the right shape for per-parent paginated children.@asConnectionalone on a child list paginates across all parents.
See also
-
@asConnectionestablishes the connection wrapper. -
@splitQuerydispatches per-parent connections through aDataLoader. -
@defaultOrderanchors the cursor. -
@orderBysupplies dynamic order. -
How-to: Sort results covers cursor stability, tie-breakers, and the collation pitfalls that bite at scale.
-
How-to: Polymorphic types covers union/interface connection emission and the multi-table
sortshape.