Skip to content

Mirror on GraphQL

You add a GraphQL adapter to the same feature — a single resolver struct, two operations, zero changes to the service. By the end of this page, POST /graphql answers { users { id name } } against the same authn, the same ability, the same Postgres rows, and the User.org field auto-resolves through a dataloader without an N+1.

A resolver is a struct decorated with #[resolver], with operations on the impl block. The reference feature wires both halves:

The crate driving this page is nest-rs-graphql, which builds on top of async-graphql. Schema discovery, dataloader registration, and the authz bridge come from the framework crate; the schema model itself is async-graphql’s.

crates/features/src/users/graphql/resolver.rs
use std::sync::Arc;
use async_graphql::{Context, Result};
use nest_rs_authz::{Create, Read};
use nest_rs_graphql::{crud, resolver};
use nest_rs_seaorm::graphql::bind;
use crate::Claims;
use crate::authn::AuthGuard;
use crate::authz::AuthzGuard;
use crate::users::{CreateUserDto, Entity as UserEntity, UpdateUserDto, User, UsersService};
#[resolver]
#[use_guards(AuthGuard, AuthzGuard)]
pub struct UsersResolver {
#[inject]
svc: Arc<UsersService>,
}
#[crud(
service = svc,
entity = UserEntity,
output = User,
create = CreateUserDto,
update = UpdateUserDto,
)]
impl UsersResolver {
#[mutation]
#[authorize(Create, UserEntity)]
async fn create_user(&self, ctx: &Context<'_>, input: CreateUserDto) -> Result<User> {
let actor = ctx.data::<Claims>()?;
let user = self.svc.create_in_org(input, actor.org_id).await?;
Ok(User::from(&user))
}
#[query]
#[authorize(Read, UserEntity)]
async fn user(&self, ctx: &Context<'_>, id: String) -> Result<Option<User>> {
Ok(bind::<UsersService, Read>(ctx, &id)
.await?
.as_ref()
.map(User::from))
}
}

#[authorize(Action, Entity)] is each operation’s access posture: the macro emits the class-level gate before your body runs and masks the returned value after it — you never call authorize or a masking function by hand. A deliberately open operation declares #[public] instead.

Symmetry with the HTTP controller is intentional — the service is the same, the inputs are the same, the policy is the same. The bridges are different:

HTTPGraphQLJob
Authorize<Create, E> extractor#[authorize(Create, E)] attributeRefuse the operation if the ability disallows it and mask the response fields
Bind<UsersService, Read> extractorbind::<UsersService, Read>(ctx, &id).await? callLoad the row through the service, refuse it on a denied ability
Ctx<Claims> extractorctx.data::<Claims>()? callRead the principal the authz bridge seeded

The same #[use_guards(AuthGuard, AuthzGuard)] declaration on the resolver struct — the markers are the same, the access graph checks the same thing.

The adapter imports the port (UsersModule), the authz bridge (AuthzGraphqlModule), and — because the entity exposes a belongs_to org relation (next section) — the org port whose service hosts the auto-emitted loader:

crates/features/src/users/graphql/module.rs
use nest_rs_core::module;
use super::resolver::UsersResolver;
use crate::authz::graphql::AuthzGraphqlModule;
use crate::orgs::OrgsModule;
use crate::users::UsersModule;
#[module(
imports = [UsersModule, OrgsModule, AuthzGraphqlModule],
providers = [UsersResolver],
)]
pub struct UsersGraphqlModule;
crates/features/src/users/graphql/mod.rs
mod module;
mod resolver;
pub use module::UsersGraphqlModule;
pub use resolver::UsersResolver;

And the feature root:

crates/features/src/users/mod.rs
mod entity;
mod module;
mod service;
pub mod http;
pub mod graphql;
pub use entity::*;
pub use module::UsersModule;
pub use service::UsersService;
pub use graphql::{UsersGraphqlModule, UsersResolver};
pub use http::{UsersController, UsersHttpModule};

The app root imports GraphqlModule::for_root(None) to mount the HTTP endpoint, and the feature’s GraphQL adapter for the operations.

apps/api/src/module.rs
UsersGraphqlModule,
GraphqlModule::for_root(None),

GraphqlModule::for_root(...) mounts POST /graphql (the endpoint) and GET /graphql (the playground) on the HTTP transport. The schema itself is composed from every #[resolver] the access graph reaches — no central queries = [...] list to keep in sync.

Terminal window
$ curl -s -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"query":"{ users { id name email } }"}' \
http://localhost:3002/graphql
{"data":{"users":[{"id":"018f…","name":"Ada","email":"ada@acme.test"}]}}

The ambient ability runs on the query — a plain user gets the same filter as the HTTP GET /users, and a missing email field on the masker drops it from the response. Same policy, two transports.

If the entity declared a belongs_to field — say org pointing at OrgsEntity — adding it to the SeaORM model is enough. The macro emits the Org PK loader on OrgsService and a #[ComplexObject] field resolver on the wire DTO. No #[field_resolver] to write, no #[dataloader] to call by hand.

crates/features/src/users/entity.rs
#[expose(name = "User", service = super::service::UsersService)]
#[sea_orm::model]
#[derive(Clone, Debug, DeriveEntityModel)]
pub struct Model {
// ... id, org_id, name, email — each carrying #[expose] ...
#[sea_orm(belongs_to, from = "org_id", to = "id")]
#[expose]
pub org: HasOne<crate::orgs::Entity>,
}

A nested query batches through the loader rather than issuing one query per parent:

Terminal window
$ curl -s -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"query":"{ users { id name org { id } } }"}' \
http://localhost:3002/graphql
{"data":{"users":[
{"id":"018f…","name":"Ada","org":{"id":"018e…"}}
]}}

The User.org resolver runs through Repo::scoped(Action::Read), so an out-of-scope org never surfaces — the row-level filter applies to each batched query the loader issues.

For tooling, commit a schema.graphql alongside the binary. The framework writes one as a side effect of the dev run when NESTRS_GRAPHQL__EMIT_SDL=1. Add the path to .gitignore for prod builds, commit it for dev:

Terminal window
$ NESTRS_GRAPHQL__EMIT_SDL=1 nestrs run dev api
INFO nest_rs::graphql: wrote schema to apps/api/schema.graphql
$ head -5 apps/api/schema.graphql
type Query {
user(id: String!): User
users(first: Int, after: String): [User!]!
}

No standalone generator binary, no CI drift check — the dev loop emits the SDL when you ask for it.

  • A UsersResolver mirroring the HTTP controller’s operations, going through the same service and the same policy.
  • A UsersGraphqlModule mounted in the app — POST /graphql answers { users { id name } } with the ambient ability filter applied.
  • An auto-resolved User.org field that batches through a dataloader and respects the row-level scope.

Next: lock it down with an e2e test →