Relations resolve themselves
Declaring a SeaORM relation on an exposed entity — and marking the
relation field #[expose] — is the whole
field-resolver story for entity-to-entity links — no
#[field_resolver], no #[dataloader] to write by hand. Pass
service = … to #[expose] and the macro emits the PK loader on the
service, the trait bridges that let other entities reach it, and a
#[ComplexObject] field resolver on the wire DTO. The ambient
Ability filters every batch under Repo::scoped(Action::Read), so a
relation cannot leak across orgs.
The declaration
Section titled “The declaration”use nest_rs_resource::expose;use sea_orm::entity::prelude::*;
#[expose(name = "User", service = super::service::UsersService, graphql)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm(table_name = "user")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] #[expose] pub id: Uuid, #[expose] pub org_id: Uuid, #[expose] pub name: String,
#[sea_orm(belongs_to, from = "org_id", to = "id")] #[expose] pub org: HasOne<crate::orgs::Entity>,}#[expose(name = "Org", service = super::service::OrgsService, graphql)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm(table_name = "org")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] #[expose] pub id: Uuid, #[expose] pub name: String,
#[sea_orm(has_many)] #[expose] pub users: HasMany<crate::users::Entity>,}That is the whole declaration — both directions now query:
$ curl -sX POST http://localhost:3000/graphql -d \ '{"query":"{ users { id org { id name } } orgs { users { name } } }"}'What the macro emits
Section titled “What the macro emits”Per #[expose(service = …)]:
- A PK loader on the service:
<Service>ById(e.g.UsersServiceById,OrgsServiceById). Reads the parent’s primary key, returns the wire DTO. - For every
belongs_torelation, an FK loader on the FK-owning service:<Service>By<FkCol>(e.g.UsersServiceByOrgId). Reads a slice of foreign keys, returns the child rows grouped by that key. - The trait impls (
PkLoadable,RelatedTo<Parent>) that let the inverse side reach the loader without naming the other service directly. - A
#[ComplexObject]field resolver on the wire DTO that calls into the loader and shapes the result.
The schema you get:
type User { id: ID! orgId: ID! name: String! org: Org # belongs_to side — PK loader on OrgsService}
type Org { id: ID! name: String! users: [User!]! # has_many side — FK loader on UsersService}The cross-entity rule
Section titled “The cross-entity rule”The FK loader belongs to the owner of the FK column — never the
consumer. Users.org_id lives on the users table, so
UsersServiceByOrgId is on UsersService. The orgs resolver reaches
it through a trait bridge, not by depending on UsersService directly.
This is the same hexagonal rule the rest of the framework follows:
when one service needs to touch another entity, it goes through the
owner’s service — never the ORM. Within a feature’s graphql/
adapter, the resolver only injects its own service; the auto-emitted
relation glue handles the rest.
Auth-scoped batches
Section titled “Auth-scoped batches”Every relation batch runs through Repo::<Entity>::scoped(Action::Read)
against the ambient Ability. A user without read access to Org
asking for user.org { name } gets null for that field, not an
error — the row does not pass the filter. A guarded GraphQL
request always carries an Ability (the guard seeds it per operation);
the ability-less unscoped path is the worker/queue side, not a request
transport — see Repo and executor.
This is the killer property: turning a per-row leak into a wire-level
omission needs no controller code and no manual if in the resolver.
Fanout-aware complexity
Section titled “Fanout-aware complexity”The auto-emitted HasMany resolver loads every child of the parent —
unbounded fanout. With async-graphql’s default 1 + child_complexity
formula, that field would barely move the score even when the loader
resolves thousands of rows. The macro adds
#[graphql(complexity = "10 * child_complexity")] for you, so a
three-level chain like organizations { teams { members { id } } }
scores 10 × 10 × 10 = 1000 — within reach of a sane
max_complexity ceiling.
BelongsTo keeps the additive default (one parent row, dropping to
zero when the ambient Ability denies it). The two relation kinds
therefore use asymmetric base scoring — multiplicative for
HasMany, additive for BelongsTo — which is the point: lists are
the cost driver. Override per field with #[expose(complexity = …)]
when the realistic fanout differs — full details in
Query limits.
Opt out: leave a single relation unexposed
Section titled “Opt out: leave a single relation unexposed”Leaving a relation field unexposed (no #[expose]) keeps auto-emission
off for that field only. The relation stays declared at the ORM level
(SeaORM still knows about it), and you can write a hand-rolled
#[field_resolver] if you need a custom shape — say a Connection<User>
with cursor pagination, or a filtered subset.
#[expose(name = "Org", service = …, graphql)]#[sea_orm::model]pub struct Model { #[expose] pub id: Uuid, #[expose] pub name: String,
// No #[expose] => not auto-resolved; own it with a #[field_resolver]. #[sea_orm(has_many)] pub users: HasMany<crate::users::Entity>,}A custom shape: the cursor connection
Section titled “A custom shape: the cursor connection”When the unexposed relation opens the slot, write the replacement on the resolver:
use async_graphql::{Context, Result, connection::*};use nest_rs_graphql::resolver;
use crate::orgs::Org;use crate::users::User;
#[resolver]pub struct OrgsResolver { #[inject] users: Arc<crate::users::UsersService>,}
#[resolver]impl OrgsResolver { #[field_resolver] async fn users( &self, _ctx: &Context<'_>, parent: &Org, first: Option<i32>, after: Option<String>, ) -> Result<Connection<String, User>> { let page = self.users.page_in_org(parent.id, first, after.as_deref()).await?; Ok(into_connection(page)) }}The connection helper, the page service method, and the User wire
DTO all stay where they belong — the resolver only translates between
the two.
Advanced: bidirectional and multi-hop graphs
Section titled “Advanced: bidirectional and multi-hop graphs”You can skip this on a first read — it only matters once a schema grows bidirectional or multi-hop relations.
Bidirectional and multi-hop graphs (org ↔ membership ↔ user) are resolved
explicitly rather than guessed: two relations that would generate the same
loader impl are caught at compile time, so you pick which side owns the
generated field and write the other by hand.
When two belongs_to relations on the same entity target the same parent,
the macro refuses with an actionable error — leave one side
unexposed (no #[expose]) and write a hand-rolled #[field_resolver], or flatten to
FK columns only.
Composite primary keys and duplicate loader targets are refused at compile time
(not a cryptic E0119). See crates/nest-rs-resource-macros/src/relations.rs
for the diagnostics.
Going further
Section titled “Going further”- Dataloaders — write a custom batch loader
(
by_name,by_tag, denormalized lookups) when the auto-emitted PK + FK loaders don’t fit. - Field resolvers — the
leave-it-unexposed escape hatch, and the single-
ComplexObjectrule in detail. - Database — the service contract every loader (auto-emitted or hand-rolled) routes through.
Built by YV17labs