Posted 4 min read

GraphQL for every API beyond the database

Our apps talk to their database directly, so the APIs we still build are the ones that do things. Every one of them speaks GraphQL, and this is why.

EngineeringAPIs

GraphQL logo
On this page
  1. Data goes to the database, behaviour goes to GraphQL
  2. Why GraphQL, and not one more REST API
  3. The schema is the contract
  4. Clients ask for exactly what they need
  5. It grows without versions
  6. It reads like the database
  7. Written in Rust, one capability at a time
  8. What GraphQL asks of you
  9. The rule of thumb

Most of our apps have almost no API.

That surprises people. An app we build connects straight to its database from the browser, as the person using it, and the database decides what they may read and write. We explained why in SurrealDB: the all-in-one database we chose over PostgreSQL. The upshot is that the usual wall of endpoints between a front end and its data is simply not there.

But not everything an app does is a question about data. Something still has to send the email, take the payment, call the model, sync with a partner. Those are the APIs left over, and every one of them we build speaks GraphQL.

Data goes to the database, behaviour goes to GraphQL#

The line is simple, and it has held up. If a request reads or writes records, it is a query against the database, under the database's permissions. If it does something, it is a GraphQL operation against a service that knows how.

Our mail server, Inbox, is the clearest example. Mail clients reach it the way they always have, over SMTP, IMAP, JMAP, CalDAV and CardDAV. Everything else reaches it over GraphQL: sending transactional email, reporting spam, managing encryption keys. Every one of those is a facade over the same store, so a message flagged in a mail client and one flagged through the API end up in exactly the same place.

Sending an email is a single mutation:

mutation Send {
	sendEmail(
		from: "sender@example.com"
		to: ["user@example.net"]
		subject: "Hello"
		textBody: "Hi from Inbox"
		idempotencyKey: "welcome-2026-09-29-001"
	)
}

The idempotency key is unique in the outbound queue, so a client that retries after a dropped connection can never send the same email twice.

The database answers questions. GraphQL does the work.

Why GraphQL, and not one more REST API#

We have built plenty of REST APIs, and they are fine. GraphQL is better at the things these APIs need most.

The schema is the contract#

A GraphQL service publishes its whole shape: every type, field and argument, with its documentation, in a single schema that any client can read. Nobody writes a client by hand; they generate one. Nobody has to ask what an endpoint returns; the schema says so, and a tool such as GraphiQL lets them try it before writing a line.

Clients ask for exactly what they need#

A REST endpoint returns what its author guessed you would want. A GraphQL query returns what you asked for, nested as deep as the data goes, in one round trip. For a phone on a train, or a Worker at the edge, fewer round trips and smaller responses are the difference you can feel.

It grows without versions#

Adding a field breaks nobody. Retiring one is a deprecation, marked in the schema with @deprecated and visible to every client and every code generator until nothing uses it. There is no /v2, and no second copy of an API to keep alive beside the first.

It reads like the database#

This is the quiet reason. A SurrealQL query and a GraphQL query have the same shape: choose the fields you want, and follow relationships to the ones you need next.

SELECT name, ->owns->company.name AS companies
FROM company:foretag;
query {
	company(id: "foretag") {
		name
		owns {
			name
		}
	}
}

An engineer who can write one can read the other, so moving from data to behaviour never means changing how you think.

Written in Rust, one capability at a time#

Our services are Rust, and async-graphql lets a schema be written as ordinary types and methods, with the documentation taken from the doc comments. In Inbox, each area of the API is its own object, and the server merges them into one schema:

#[derive(MergedObject, Default)]
pub struct RootMutation(Mutation, SecondaryMutation, SpamAdminMutation);

Sending mail, training the spam filter and administering it live apart in the code and together in the API. Adding a capability means adding an object, not reshaping the ones already there.

What GraphQL asks of you#

It is not free, and the costs are worth knowing before you start:

  • Caching moves. Most GraphQL travels as a POST to one address, which a CDN will not cache on its own. Caching belongs in the service, or in persisted queries sent as GET.
  • A query can ask for a lot. Nesting is the point, and it is also a way to ask a service for far more work than anyone intended. A public schema needs limits on depth and cost, and async-graphql has both built in.
  • Lists invite N+1. Resolving a list of things, each with its own lookups, is a classic way to run one query per row. Batching with data loaders is the answer, and it is easiest designed in from the start.
  • Errors live in the body. A response can be an HTTP 200 and still carry errors, so clients have to read them instead of trusting the status code.

None of these is a reason to avoid GraphQL. They are the reasons to learn it properly.

The rule of thumb#

Records go to the database, where the permissions already live. Anything that acts goes to GraphQL, where a schema says exactly what it can do. Two ways of talking, each doing what it does best, and nothing in between that has to be kept in step with either.

Written by

  • CB

    Chiru Boggavarapu

    Founder & Chief Executive