Skip to content

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.

src/users/entity.rs
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>,
}
src/orgs/entity.rs
#[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:

Terminal window
$ curl -sX POST http://localhost:3000/graphql -d \
'{"query":"{ users { id org { id name } } orgs { users { name } } }"}'

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_to relation, 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 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.

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.

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>,
}

When the unexposed relation opens the slot, write the replacement on the resolver:

src/orgs/graphql/resolver.rs
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.

  • 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-ComplexObject rule in detail.
  • Database / CRUD — the service contract every loader (auto-emitted or hand-rolled) routes through.