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

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 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.

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

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::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.

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

Built by YV17labs