@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!]! @asConnection on a bare list. The generator manufactures <ParentType><FieldName>Connection and a matching Edge type, registers them on the schema, and rewrites the field’s return type to point at the synthesised name. Query.stores: [Store!]! @asConnection becomes Query.stores: QueryStoresConnection!.

  • Structural (declared). myField: FilmsConnection! where the SDL already declares FilmsConnection { edges: [FilmsEdge!]!, nodes: [Film!]!, pageInfo: PageInfo!, totalCount: Int }. The rewrite recognises the edges.node shape and reuses the SDL type as-is. @asConnection may 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.customers returns the global customer list; Store.customers returns one store’s customers. The element type is Customer in both cases, but the result sets are scoped differently. A shared CustomersConnection would 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) and Query.customers produce 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 @orderBy enum 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 totalCount resolver when the client selects the field. Queries that ask for edges, nodes, or pageInfo and skip totalCount pay zero count cost. This is why totalCount is Int (nullable), not Int!: 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 same Table<?> and Condition the page used (carried on the ConnectionResult for root connection fetchers), so the count matches the page’s predicate; it does not duplicate the page’s projection or seek/limit.

  • Returns null on split-connection paths. Per-parent paginated children built with @asConnection + @splitQuery (e.g. Film.actorsConnection) supply a ConnectionResult without (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. Selecting totalCount on a split-connection carrier returns null until the per-parent count plumbing lands. This is observable: clients asking for totalCount on Film.actorsConnection see null, not the per-film actor count.

  • Returns null on multi-table polymorphic connections. Query.searchConnection and friends emit a per-branch UNION ALL wrapped in a derived table. The polymorphic count query is a separate roadmap follow-up; until it lands, totalCount on these carriers also returns null.

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. Use N. No cap.

  • Client omits first:. Use the directive’s defaultFirstValue: (the @asConnection(defaultFirstValue: 5) argument), or the generator’s DEFAULT_PAGE_SIZE = 100 when defaultFirstValue: is also omitted.

  • Backward pagination (last: N). Same shape, last substitutes for first in 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 @defaultOrder or in an @order enum 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 @orderBy cannot 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

  • @asConnection applies to list-typed fields. Single-cardinality @asConnection is rejected at classify time.

  • The field must have a stable order. Either @defaultOrder is set, or @orderBy is 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’s edges.node.

  • totalCount is null on split-connection and multi-table polymorphic carriers. Document this for clients; do not surface 0 as a substitute.

  • totalCount is 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 + @splitQuery is the right shape for per-parent paginated children. @asConnection alone on a child list paginates across all parents.

See also