All posts

GraphQL API Layer

9 min read · ComputeFlux Team
Economic Protocol

Fork in the road — REST with multiple requests vs GraphQL with one request

In one sentence. ComputeFlux runs a rigid, standard-imitating API for AI requests and a flexible one for everything else, because those two audiences want genuinely incompatible things.

Picture it like this. A restaurant with both a fixed set menu and an à la carte list. The fixed menu exists because a thousand delivery apps already know how to order from it and would break if a single dish were renamed. The à la carte list exists because the diner at the table wants exactly six things and none of the rest.

Why it matters. Short article, unusually clean lesson: the right API shape depends entirely on who controls the client. When you don't control it, you copy the existing standard exactly. When you do, you optimize for the screen it's rendering.


A backend API that serves both an end-user web application and an AI gateway with programmatic SDK access faces contradictory requirements. The web app needs flexible, nested queries that join user profiles, wallet balances, and model listings into a single round-trip. The AI gateway needs HTTP-level compatibility with OpenAI's REST SDK — structured request bodies, predictable response schemas, standard HTTP semantics. Serving both from a single monolithic API produces either over-fetching on one side or under-fetching on the other. ComputeFlux splits the difference: GraphQL for application-facing operations (authentication, wallet management, provider/model registry, edge routing) and REST for the AI gateway relay (OpenAI-compatible /v1/chat/completions, /v1/models — the surface covered in Article 12, Protocol Adaptor & Compatibility Matrix). This isn't an aesthetic choice; it's a consequence of who controls the client.

REST for the Gateway, GraphQL for the Application

The AI gateway's REST endpoints exist for a specific purpose: drop-in compatibility with OpenAI's SDK. Thousands of applications — from VS Code's Copilot plugin to LangChain pipelines — speak OpenAI's REST protocol natively, and changing the protocol would break every one of them. The gateway has to mimic OpenAI's wire format exactly, down to the object: "chat.completion.chunk" field in streaming SSE events and the usage.prompt_tokens field in the response body. REST, here, is a compatibility layer, not a design choice.

Two API Surfaces

The application-facing API has different constraints. The web dashboard's provider detail page needs the provider's name, logo, region, active model count, model list, pricing, and availability status — all fetched in one request to avoid a waterfall of five sequential calls. A REST endpoint offering this would either return a massive response with every possible nested field (over-fetching) or force the client to compose the data from multiple endpoints (under-fetching plus N+1 requests). GraphQL's allowlist approach — the client specifies exactly which fields it needs — eliminates over-fetching and the client-side request waterfall. The server-side analogue of N+1 — per-field store lookups — is a separate problem the resolver layer must manage, and this article is candid about where that trade-off sits.

The GraphQL layer is presentational, not authoritative. All data originates from PebbleDB (sealed storage within the TEE) and the same underlying contract/query layer regardless of which API surface exposes it. The REST gateway's model-listing endpoint (HandleListModels) and the GraphQL edge resolvers both ultimately read from the same gateway contract state — the REST path through a cached lookup helper, the GraphQL path through the gateway.GatewayQuery contract-query API directly. Adding a new API surface — gRPC, WebSocket — wouldn't require duplicating the underlying chain-state access, only a new adapter reading from the same contract layer.

The Resolver Pattern: A Shared Dependency-Injection Root, Not a Uniform Service Facade

A common anti-pattern in gqlgen-based GraphQL servers is embedding business logic directly in resolver methods — the code generation encourages it, since gqlgen generates empty resolver methods with the correct signatures and the developer fills them in. Over time resolvers accumulate database queries, permission checks, and data transformation logic, becoming untestable without a full GraphQL execution context.

ComputeFlux doesn't fully avoid this. Several resolver methods, especially in the auth and edge domains, do real work inline — building a runtime and a contract-query struct, running the query, shaping the result — instead of delegating to a one-line service call.

What it does have is a single Resolver struct in graph/resolver.go acting as the dependency-injection root for every domain. It carries a TeeChain handle for contract and query access, an Indexer, and a handful of purpose-built services: WalletSvc for wallet derivation, signing, and key encryption, plus EmailSvc, OAuthSvc, and OTPSvc — the login-flow pieces Article 16 covers.

Resolvers reach for those services when the logic is genuinely service-shaped: deriving a wallet address, verifying an OTP, exchanging an OAuth code. They reach straight into the contract layer (tee-chain/runtime/module/...) when it's a plain read or write against chain state.

The benefit isn't that resolvers stay thin. It's that one shared, testable place — the Resolver struct — owns every cross-cutting dependency. New resolvers wire into existing services instead of each inventing its own route to the chain, the wallet subsystem, or the email and OAuth providers.

The N+1 Query Problem, in Principle

The N+1 problem is the performance pathology that makes naive GraphQL servers unusable at scale. Consider:

query {
  listProviders { id name models { id name inPrice } }
}

If listProviders returns many providers and each models field resolver fires a separate database query, the server performs one query for the provider list plus one per provider for its models — mostly avoidable. Unmitigated, this scales linearly with result size, each extra query paying its own PebbleDB iterator setup and key-prefix scan.

The standard fix is Facebook's DataLoader pattern. Rather than fetching once per resolver invocation, the resolver records the requested IDs and defers to a batch function that fires after all IDs for the current tick are collected — many small lookups collapsing into one.

ComputeFlux doesn't do that. Its resolvers query the store layer directly, per field, with no request-scoped batching layer. That's a real trade-off to be aware of for nested list queries against the model/provider registry, not a problem the codebase already mitigates.

Bearer Token Authentication and Refresh

The GraphQL API authenticates through an opaque Bearer token in the HTTP Authorization header, in three phases.

Issuance follows successful authentication, whether by OTP verification or OAuth callback. The server derives or looks up the user's EVM wallet address, then mints an API-key-style gateway token as the session's bearer Token, plus a separate, longer-lived RefreshToken. This is the same kind of token the REST gateway uses for API-key auth — not a self-contained signed JWT a client can decode.

Validation happens in AuthMiddleware. It reads the Bearer <token> header, resolves it through gateway.FindTokenByKey — the identical lookup path the REST relay handler uses — and injects the resulting record into the request context via GatewayTokenFromContext. Downstream resolvers read the authenticated owner and address off that record rather than trusting any client-supplied address parameter.

Refresh is a conventional rotate-on-use flow. The refreshToken mutation looks up the presented refresh token and, if valid, mints both a fresh API-key token and a fresh refresh token, invalidating the old one immediately. That limits the blast radius of a leaked refresh token: it works once, and the legitimate client's next refresh kills it.

One footnote. The codebase also contains an HMAC-SHA256-signed, 24-hour-expiry JWT issuer in pkg/services/auth, but it doesn't appear to be wired into this request path. The live flow runs on gateway tokens and refresh tokens. Article 16 has the full OTP-versus-OAuth trade-offs and how the wallet address is derived.

Schema Design: Splitting by Domain Instead of One Monolith

A single schema.graphqls holding every type, query, and mutation would be simpler: one file to read, one set of generated resolvers, one server instance. ComputeFlux splits it into domain-scoped .graphqls files instead, each compiled by gqlgen into its own *.resolvers.go:

  • auth — login, OTP, wallet derivation, profile and guardian mutations
  • edge — the model/provider/route registry and personal-GPU edge routing
  • chain — network and validator info
  • contract — generic contract dry-run calls
  • early_access — referral and points program
  • secret — encrypted environment variables

Worth noting what's absent: governance proposal browsing and voting isn't exposed through GraphQL at all. The Gov contract from Article 17 is a separate concern from this API surface.

Independent evolution, not independent deployment: each domain's schema and generated resolver file can change without regenerating the others — a change to the auth schema doesn't touch edge's generated code, and vice versa. That matters because the domains evolve at very different rates: auth and edge see frequent changes as new login providers, models, and routing fields are added, while chain and contract are comparatively stable, generic surfaces.

Authorization boundaries: auth's mutations (updating profile, setting guardians) modify user-specific state and require proof of ownership, enforced via the shared AuthMiddleware/AuthCheck directive. edge's mutations that touch personal-GPU registration involve stake and slash conditions of their own. Splitting the schema by domain keeps each file's authorization story legible, rather than scattering per-field authorization directives across one large file.

The separation costs joins. A question spanning two domains — a user's wallet info from auth and their model/route usage from edge — takes two GraphQL operations, not one joined query. gqlgen compiles each file's types independently, using extend type Query and extend type Mutation to hang each domain's operations off the shared root types. The current deployment accepts that, because the web dashboard's state management parallelizes independent queries anyway.


Key Takeaways

  • The rigid API isn't a design decision, it's an obligation. Thousands of existing tools already speak it, down to individual field names. Improving it would break them, so it gets imitated exactly.
  • The flexible API exists because dashboards ask compound questions. One screen wants a provider's name, region, model list, pricing, and status together. A fixed endpoint either ships all of it to everyone or forces five sequential round-trips.
  • Both surfaces read the same underlying chain state. The API layer is presentation only — adding a third surface later wouldn't mean duplicating how data is fetched.
  • An honest gap, named rather than hidden. The standard batching mitigation for nested list queries isn't implemented, so a nested query over the registry can fan out into many small lookups.
  • Splitting the schema by domain buys independent evolution and costs joins. Fast-changing areas don't force regeneration of stable ones; the price is two round-trips for a question spanning two domains.

The token that authenticates every one of these calls has to come from somewhere — and in a system built on wallets and signatures, "somewhere" is usually a twelve-word phrase the user is told never to lose.

Next — Article 16: Web3Auth Unified Login: how to give someone a crypto wallet without ever showing them a seed phrase, and the honest cost of that convenience.