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)]#[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)]#[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 simply does not pass the filter. A WS or GraphQL
request without an Ability filter gets unscoped reads, correct for
system jobs.
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 = …)]#[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::{field_resolver, 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: compile-time caveats on complex graphs (Phase 0)
Section titled “Advanced: compile-time caveats on complex graphs (Phase 0)”You can skip this on a first read — it only matters once a schema grows bidirectional or multi-hop relations.
Bidirectional or multi-hop graphs (org ↔ membership ↔ user) can produce
conflicting auto-generated loader impls at compile time. Prefer scalar FK
columns on early entities and add GraphQL relation fields once the schema
stabilises.
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.
Where to go next
Section titled “Where to go next”- Dataloaders — write a custom batch loader
(
by_name,by_tag, denormalized lookups) when the auto-emitted PK + FK loaders don’t fit. Cross-link to Database / Dataloaders. - Field resolvers — the
leave-it-unexposed escape hatch, and the single-
ComplexObjectrule in detail. - Database / CRUD — the service contract every loader (auto-emitted or hand-rolled) routes through.