2026-07-24
Bun, TypeGraphQL, TypeORM, and choosing a GraphQL client
A tested decorator and cache proof, plus conditional Apollo, urql, and Relay guidance for Next.js products.
Decorator colocation can make a small GraphQL service pleasant to navigate: the persistence mapping and the output field live beside the TypeScript property. It is not a reason to make the database entity the API contract, and it does not decide the frontend client. I tested the narrow server and cache claims below with pinned packages, then compared Apollo Client, urql, and Relay from their documentation.
Tested scope
The executable proof ran on Bun 1.3.14 with TypeGraphQL 2.0.0-rc.3, TypeORM 0.3.28, GraphQL 16.12.0, reflect-metadata 0.2.2, DataLoader 2.2.3, and sql.js 1.13.0. It also ran Apollo Client 4.2.8 and @urql/core 6.0.3 with Graphcache 9.0.1. Relay 21.0.1 was source-reviewed only.
bun run typecheck and bun run verify produced this exact result:
PASS schema-nullability relation-results dataloader-batching transaction-rollback migration-up-down apollo-cache urql-graphcacheThe proof initialized an in-memory sql.js data source, ran a migration, undid it, then ran it again. It inserted two authors and posts in a transaction, confirmed an intentional transaction failure rolled back, built a schema, and resolved both post authors through one request-scoped DataLoader batch. It also verified an Apollo normalized entity update and a Graphcache network-first then cache-only read with one query fetch.
This is the concise shape that was executed:
@ObjectType()
@Entity({ name: "posts" })
class Post {
@Field(() => ID) @PrimaryGeneratedColumn() id!: number;
@Field() @Column() title!: string;
@Field(() => Int) @Column() authorId!: number;
@ManyToOne(() => Author, (author) => author.posts, { nullable: false })
author!: Author;
}
@InputType()
class CreatePostInput {
@Field() title!: string;
@Field(() => Int) authorId!: number;
}The result demonstrates that these decorators can coexist on those classes in this exact stack. It does not test a production database driver, deployment image, TypeORM CLI, authorization, lazy or eager loading, Next.js SSR, or Relay runtime behavior.
Source-reviewed scope
Source review was separate from execution. On 2026-07-24, I reviewed the live TypeGraphQL documentation at its 2.0.0-rc.4 state and the live TypeORM documentation at its 1.x state. The executable proof remains pinned to TypeGraphQL 2.0.0-rc.3 and TypeORM 0.3.28; a current-doc citation is not evidence that the proof ran those newer documentation states. Where a statement depends on TypeORM 0.3.28 mechanics, the nearby citation also points to the 0.3.28 tag or the official v0 documentation.
The live TypeGraphQL 2.0.0-rc.4 docs document TypeORM decorator colocation and require legacy TypeScript decorators, emitted decorator metadata, and an early reflect-metadata import. Its reflection cannot retain every TypeScript type, so arrays, promises, unions, and circular references need explicit GraphQL type functions. See TypeGraphQL installation, types and fields, and its TypeORM example.
The benefit is local clarity: an editor rename and a code review can see a column and its deliberately exposed field together. The exposure risk is equally local: @Field turns a persistence property into part of a public schema. Keep secrets and internal columns undecorated, but do not mistake that allow-list for authorization.
Use separate shapes when the concerns differ. An entity owns database names, indexes, defaults, foreign keys, and relation behavior. An output type owns stable API names, computed values, redaction, and client-facing nullability. An input type owns writable fields and operation-specific validation. GraphQL itself separates input and output type systems. The GraphQL specification and TypeGraphQL resolver guidance support that split. In particular, do not bind client input wholesale into an entity with tenant IDs, roles, generated IDs, soft-delete fields, or cascades.
Nullability is three separate contracts. TypeGraphQL output fields are non-null unless marked nullable, TypeORM columns default to non-null, and TypeORM relations default to nullable. For the 0.3.28 defaults used by the proof, see the tagged column metadata source and relation metadata source; the current 1.x TypeORM relations docs were reviewed separately. A nullable relation decorated as a non-null GraphQL field can fail when a related record is absent, filtered, or redacted. GraphQL non-null errors propagate to a nullable boundary. Align the database mapping, resolver behavior, and schema deliberately. TypeGraphQL nullability and GraphQL execution describe the other rules.
Neither TypeGraphQL nor TypeORM presents Bun as its primary supported runtime. Bun has also documented decorator metadata edge cases around inherited TypeScript configuration. Keep experimentalDecorators and emitDecoratorMetadata in the application configuration, type-check separately, pin the stack, and test the actual driver and deployment image. The isolated proof is evidence for the listed versions, not a broad Bun support claim. See Bun TypeScript handling, the current 1.x TypeORM supported platforms, and Bun issue 6326.
Relations, policy, and writes
Do not use eager relations as a universal N+1 fix. They can overfetch and multiply joined rows, while lazy relations can hide per-field I/O. Prefer bounded joins for predictable root reads and request-scoped DataLoaders for fan-out fields. DataLoader recommends a new loader collection per request, which also avoids sharing cached authorization results across users. TypeORM eager and lazy relations and the DataLoader README document those mechanics.
Authorization belongs in the resolver boundary and in every repository or loader query that needs tenant, ownership, soft-delete, or column restrictions. @Authorized delegates the runtime decision to a TypeGraphQL auth checker; it does not make a previously loaded entity safe for every caller. TypeGraphQL authorization explains the decorator and checker.
Inside a TypeORM transaction, use the callback's transactional entity manager, not the global manager. A loader that captured a global repository can otherwise read outside the transaction. Create transaction-local loaders when needed, clear or prime affected request-local keys after writes, and discard transaction-scoped objects at commit or rollback. The executed 0.3.28 behavior is traceable to its tagged EntityManager transaction source; the current 1.x TypeORM transactions docs and DataLoader cache clearing cover the corresponding APIs.
For schema changes, keep synchronize off with production data and use reviewed migrations. Code-first improves navigation and refactoring, but it also makes a database edit, an API edit, and metadata behavior easy to blur together. Emit SDL in CI, review schema diffs and migrations independently, and separately verify migration generation, run, revert, ESM loading, and the production driver on Bun. The proof's 0.3.28 migration path is traceable to the tagged MigrationExecutor source; the current 1.x migration guidance and CLI documentation are Node-oriented, so they are not a substitute for that Bun verification.
Client choice depends on product shape
All three clients can use normalized data, paginate, and update after mutations, but they make different tradeoffs.
| Concern | Apollo Client | urql | Relay |
|---|---|---|---|
| Normalized cache | InMemoryCache is normalized around stable cache IDs |
Default caching is document-oriented; Graphcache adds normalized caching | Normalized store is part of the Relay model |
| Pagination | Field policies define argument identity and merge or read behavior | Graphcache provides pagination helpers and resolvers when enabled | Connections and pagination fragments are first-class conventions |
| Fragments and colocation | Fragments work with useFragment; data masking is opt-in |
Fragments compose documents; reviewed first-party docs do not describe a comparable client-level masking contract | Compiler artifacts, fragment ownership, and masking enforce component data boundaries |
| Mutation updates | Returned entities merge, while list membership may need update, cache.modify, or refetching |
Simple document caching often re-executes; Graphcache offers updates, invalidation, and optimistic results | Matching IDs update records; connections use directives or updaters |
| SSR and Next.js | First-party Next integration supports request-scoped clients and streaming patterns | SSR exchange and @urql/next provide an integration surface |
Next.js can be used out of the box, and the Babel plugin has a Next.js compiler option; source review found no dedicated first-party Next App Router SSR/RSC integration package comparable to Apollo's |
| Adoption requirement | Need stable IDs and explicit field policies for normalized cache behavior | Start with document caching; adopt Graphcache only when normalized cross-query consistency, pagination, or optimistic updates are required | Adopt the compiler workflow, global object identification, connections, and fragment ownership conventions |
Apollo's normalized cache needs stable IDs. Its pagination field policies make cache argument identity and merge behavior explicit, and mutations may require a deliberate list update. Apollo caching, pagination, mutations, and Next.js integration describe that surface.
urql's document caching and refetching suit flows that do not need normalized cross-query consistency. Graphcache adds normalized entities, pagination, optimistic updates, and explicit cache updates when those requirements appear. urql architecture, Graphcache, and urql SSR describe the split. The Graphcache proof only verifies one entity read path, not pagination, mutation policy, or application SSR.
Relay makes fragment colocation and data masking a schema and compiler discipline. Its connections standardize pagination, and its store updater model standardizes mutation work, but that consistency needs compiler artifacts, global object identification, and a team willing to follow the conventions. Relay's site says Next.js can be used out of the box, and its Babel plugin guide documents a Next.js compiler option. In this source review, I found no dedicated first-party Next App Router SSR/RSC integration package comparable to Apollo's. See Relay architecture, the compiler, fragments, pagination, and mutations. These are source-reviewed statements only for Relay 21.0.1.
Choose Apollo Client when the product needs a first-party Next.js App Router SSR/RSC integration and a normalized cache with explicit field policies. Choose urql when document caching and refetching meet the consistency requirement, adding Graphcache only when normalized cross-query updates, pagination, or optimistic results become necessary. Choose Relay when fragment ownership, compiler artifacts, global object identification, and connection conventions are requirements the team is ready to adopt; it can run with Next.js, but choose Apollo when the dedicated first-party App Router SSR/RSC integration is the deciding requirement. That is the decisive condition: required cache behavior and Next.js runtime architecture should select the client, not a product-size label.