Skip to content

Personnel Management System

This tutorial builds a personnel management system on top of the server workflow introduced in the Tutorial. Start with a concrete Operator object, then generalize the API with Person and Organization interfaces. The finished schema adds recursive relationships, heterogeneous search, request Context, and nullable error propagation.

The example keeps its data deliberately small. It contains four operator records: Necrass, Reed, Reed the Flame Shadow, and Amiya.

The tutorial uses names and short factual relationships only. It does not copy game text, character art, logos, dialogue, or profile descriptions.

The final API supports these operations:

  • Look up one concrete Operator by codename.
  • Browse personnel through the Person interface.
  • Browse a company and a faction through the Organization interface.
  • Follow relationships from one person to another.
  • Search across operators and organizations through a Union.
  • Return a field-level authorization error without discarding the rest of the operator record.

Each section follows the same workflow:

  1. Change schema/schema.graphql.
  2. Run cargo build to validate the SDL and synchronize resolver declarations.
  3. Keep each generated declaration and normally replace only its new unimplemented!() body. A section explicitly shows the full declaration when its Context type must also change.
  4. Run the server and execute the section’s query.

The code in this guide was verified in a temporary Axum project created by the current necrassrs-cli and compiled against the current NecrassRs checkout.

Install the CLI as described in the installation guide, then create a new Axum application:

Terminal window
necrass init personnel-system --framework axum
cd personnel-system
cargo build

The initialized application already contains the Axum route, embedded schema, dispatcher, and a scalar greeting resolver. The next section replaces that schema and lets the ordinary Cargo build synchronize the new declarations.

Begin with a direct Object result. Replace schema/schema.graphql with:

type Operator {
id: ID!
codename: String!
}
type Query {
operator(codename: String!): Operator
}

Run the build:

Terminal window
cargo build

The old scalar resolver is removed and src/resolvers.rs receives an application-owned Operator representation and field-level resolver stubs.

Replace the generated unit struct with an identifier-bearing representation and add the small in-memory data set:

#[allow(non_camel_case_types)]
#[derive(Clone, Copy)]
pub struct Operator {
id: &'static str,
}
const OPERATOR_IDS: [&str; 4] = [
"necrass",
"reed",
"reed-the-flame-shadow",
"amiya",
];
fn operator_name(id: &str) -> Option<&'static str> {
match id {
"necrass" => Some("Necrass"),
"reed" => Some("Reed"),
"reed-the-flame-shadow" => Some("Reed the Flame Shadow"),
"amiya" => Some("Amiya"),
_ => None,
}
}
fn operator(id: &'static str) -> self::Operator {
self::Operator { id }
}

Keep the generated resolver declarations and replace their bodies as follows:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::operator, C>
for self::Query
{
type Output = Option<self::Operator>;
async fn resolve(
&self,
_context: &C,
args: crate::generated::types::Query::operator::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
Ok(OPERATOR_IDS
.into_iter()
.find(|id| operator_name(id) == Some(args.codename.as_str()))
.map(operator))
}
}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Operator::id, C>
for self::Operator
{
type Output = necrassrs::Id;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Operator::id::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
Ok(necrassrs::Id::from(self.id))
}
}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Operator::codename, C>
for self::Operator
{
type Output = String;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Operator::codename::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
operator_name(self.id)
.map(str::to_owned)
.ok_or_else(|| necrassrs::ResolverError::new("Unknown operator"))
}
}

Format and run the application:

Terminal window
cargo fmt
cargo run

Open GraphiQL at http://127.0.0.1:3000/graphql and run:

{
operator(codename: "Necrass") {
id
codename
}
}

The response contains a concrete Object with independently resolved fields:

{
"data": {
"operator": {
"id": "necrass",
"codename": "Necrass"
}
}
}

No Interface is involved yet. Query.operator returns Operator, and selection of id and codename invokes the two Operator field resolvers.

Now add a common contract for person-shaped records. Update the schema:

interface Person {
id: ID!
displayName: String!
}
type Operator implements Person {
id: ID!
displayName: String!
codename: String!
}
type Query {
operator(codename: String!): Operator
personnel: [Person!]!
}

After cargo build, the new root resolver has this generated output contract:

type Output = Vec<crate::generated::types::Person<self::Operator>>;

Person is generated as an enum over its possible concrete object types. Add helpers that wrap each Operator in the corresponding variant:

fn person(id: &'static str) -> crate::generated::types::Person<self::Operator> {
crate::generated::types::Person::Operator(operator(id))
}
fn personnel() -> Vec<crate::generated::types::Person<self::Operator>> {
OPERATOR_IDS.into_iter().map(person).collect()
}

Implement the new displayName field using the same stored identity:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Operator::displayName, C>
for self::Operator
{
type Output = String;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Operator::displayName::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
operator_name(self.id)
.map(str::to_owned)
.ok_or_else(|| necrassrs::ResolverError::new("Unknown operator"))
}
}

Then implement the generated Query.personnel stub:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::personnel, C>
for self::Query
{
type Output = Vec<crate::generated::types::Person<self::Operator>>;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Query::personnel::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
Ok(personnel())
}
}

Stop the application from the previous section if it is still running, then format and restart it:

Terminal window
cargo fmt
cargo run

Open GraphiQL at http://127.0.0.1:3000/graphql. Query common Interface fields directly and use a fragment for the Operator-specific codename field:

{
personnel {
__typename
id
displayName
... on Operator {
codename
}
}
}

The list contains the four records and identifies their concrete type:

{
"data": {
"personnel": [
{
"__typename": "Operator",
"id": "necrass",
"displayName": "Necrass",
"codename": "Necrass"
},
{ "__typename": "Operator", "id": "reed", "displayName": "Reed", "codename": "Reed" },
{
"__typename": "Operator",
"id": "reed-the-flame-shadow",
"displayName": "Reed the Flame Shadow",
"codename": "Reed the Flame Shadow"
},
{ "__typename": "Operator", "id": "amiya", "displayName": "Amiya", "codename": "Amiya" }
]
}
}

This data set has one Person implementation, so the Interface does not yet demonstrate a choice between object types. Its purpose here is to establish the return contract used by organization membership and person-to-person relationships. The next section introduces an Interface with two concrete implementations.

3. Model organizations with multiple implementations

Section titled “3. Model organizations with multiple implementations”

An operator may be associated with different kinds of organizations. Add an Organization Interface, two concrete implementations, and the corresponding fields:

interface Organization {
id: ID!
name: String!
}
type Company implements Organization {
id: ID!
name: String!
personnel: [Person!]!
}
type Faction implements Organization {
id: ID!
name: String!
members: [Person!]!
}

Add affiliation to Operator:

type Operator implements Person {
id: ID!
displayName: String!
codename: String!
affiliation: Organization
}

Add an organization list to Query:

type Query {
operator(codename: String!): Operator
personnel: [Person!]!
organizations: [Organization!]!
}

Run cargo build. NecrassRs creates Company and Faction unit structures and uses a generated enum for abstract results:

type Output =
Vec<crate::generated::types::Organization<self::Company, self::Faction>>;

Keep the generated unit structures. Replace the bodies for their leaf and membership resolvers with the following expressions:

Resolver Body
Company.id Ok(necrassrs::Id::from("rhodes-island"))
Company.name Ok("Rhodes Island".to_owned())
Company.personnel Ok(personnel())
Faction.id Ok(necrassrs::Id::from("dublinn"))
Faction.name Ok("Dublinn".to_owned())
Faction.members Ok(vec![person("necrass"), person("reed")])

In the generated Query.organizations implementation, keep the declaration and replace only the unimplemented!() body. The completed resolver selects the concrete variants explicitly:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::organizations, C>
for self::Query
{
type Output =
Vec<crate::generated::types::Organization<self::Company, self::Faction>>;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Query::organizations::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
Ok(vec![
crate::generated::types::Organization::Company(self::Company),
crate::generated::types::Organization::Faction(self::Faction),
])
}
}

Do the same for the generated Operator.affiliation implementation. The field uses the same generated enum. This tutorial associates the Necrass record with Dublinn and the other records with Rhodes Island:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Operator::affiliation, C>
for self::Operator
{
type Output = Option<
crate::generated::types::Organization<self::Company, self::Faction>,
>;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Operator::affiliation::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
let organization = match self.id {
"necrass" => crate::generated::types::Organization::Faction(self::Faction),
"reed" | "reed-the-flame-shadow" | "amiya" => {
crate::generated::types::Organization::Company(self::Company)
}
_ => return Err(necrassrs::ResolverError::new("Unknown operator")),
};
Ok(Some(organization))
}
}

Query fields shared by the Interface, then select fields specific to each implementation:

{
organizations {
__typename
id
name
... on Company {
personnel {
displayName
}
}
... on Faction {
members {
displayName
}
}
}
}

The __typename field shows which generated enum variant was returned:

{
"data": {
"organizations": [
{
"__typename": "Company",
"id": "rhodes-island",
"name": "Rhodes Island",
"personnel": [
{ "displayName": "Necrass" },
{ "displayName": "Reed" },
{ "displayName": "Reed the Flame Shadow" },
{ "displayName": "Amiya" }
]
},
{
"__typename": "Faction",
"id": "dublinn",
"name": "Dublinn",
"members": [{ "displayName": "Necrass" }, { "displayName": "Reed" }]
}
]
}
}

Relationships lead from one person record to another. Add an enum, a relationship Object, and a non-null list on Operator:

enum RelationshipKind {
SIBLING
ALTERNATE_FORM
COOPERATES_WITH
}
type PersonRelationship {
kind: RelationshipKind!
person: Person!
}
type Operator implements Person {
id: ID!
displayName: String!
codename: String!
affiliation: Organization
relationships: [PersonRelationship!]!
}

After cargo build, replace the generated PersonRelationship unit structure with:

#[allow(non_camel_case_types)]
#[derive(Clone, Copy)]
pub struct PersonRelationship {
kind: &'static str,
person_id: &'static str,
}

Operator and the existing operator(id) helper remain unchanged. The relationship kind and related-person identifier belong to PersonRelationship, not Operator.

The operator resolver returns finite relationship records. Recursive GraphQL selection does not require a recursively sized Rust value because each edge stores only the next person’s identifier:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Operator::relationships, C>
for self::Operator
{
type Output = Vec<self::PersonRelationship>;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::Operator::relationships::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
let relationships = match self.id {
"necrass" => vec![PersonRelationship {
kind: "SIBLING",
person_id: "reed",
}],
"reed" => vec![
PersonRelationship {
kind: "SIBLING",
person_id: "necrass",
},
PersonRelationship {
kind: "ALTERNATE_FORM",
person_id: "reed-the-flame-shadow",
},
],
"reed-the-flame-shadow" => vec![
PersonRelationship {
kind: "ALTERNATE_FORM",
person_id: "reed",
},
PersonRelationship {
kind: "COOPERATES_WITH",
person_id: "amiya",
},
],
"amiya" => vec![PersonRelationship {
kind: "COOPERATES_WITH",
person_id: "reed-the-flame-shadow",
}],
_ => return Err(necrassrs::ResolverError::new("Unknown operator")),
};
Ok(relationships)
}
}

Map the stored relationship name to the generated GraphQL enum:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::PersonRelationship::kind, C>
for self::PersonRelationship
{
type Output = crate::generated::types::RelationshipKind;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::PersonRelationship::kind::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
match self.kind {
"SIBLING" => Ok(crate::generated::types::RelationshipKind::SIBLING),
"ALTERNATE_FORM" => {
Ok(crate::generated::types::RelationshipKind::ALTERNATE_FORM)
}
"COOPERATES_WITH" => {
Ok(crate::generated::types::RelationshipKind::COOPERATES_WITH)
}
_ => Err(necrassrs::ResolverError::new("Unknown relationship")),
}
}
}

Return the related person through the Person Interface:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::PersonRelationship::person, C>
for self::PersonRelationship
{
type Output = crate::generated::types::Person<self::Operator>;
async fn resolve(
&self,
_context: &C,
_args: crate::generated::types::PersonRelationship::person::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
Ok(person(self.person_id))
}
}

The query can follow the graph for as many statically written levels as it needs:

{
operator(codename: "Necrass") {
codename
relationships {
kind
person {
displayName
... on Operator {
relationships {
kind
person {
displayName
}
}
}
}
}
}
}

GraphQL selection depth is finite even though the schema permits returning to Person repeatedly. Each selected Object is completed one level at a time.

5. Search heterogeneous results with a Union

Section titled “5. Search heterogeneous results with a Union”

Interfaces expose fields shared by all implementations. A Union only identifies which Object types may appear, so every object-specific selection uses a fragment. Add the search result and Query field:

union SearchResult = Operator | Company | Faction
type Query {
operator(codename: String!): Operator
personnel: [Person!]!
organizations: [Organization!]!
search(text: String!): [SearchResult!]!
}

Run cargo build. The generated result contract lists every Union member:

type Output = Vec<
crate::generated::types::SearchResult<self::Operator, self::Company, self::Faction>,
>;

Implement a small case-insensitive search:

impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::search, C>
for self::Query
{
type Output = Vec<
crate::generated::types::SearchResult<self::Operator, self::Company, self::Faction>,
>;
async fn resolve(
&self,
_context: &C,
args: crate::generated::types::Query::search::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
let text = args.text.to_lowercase();
let mut results = OPERATOR_IDS
.into_iter()
.filter(|id| {
operator_name(id)
.is_some_and(|name| name.to_lowercase().contains(text.as_str()))
})
.map(|id| {
crate::generated::types::SearchResult::Operator(operator(id))
})
.collect::<Vec<_>>();
if "rhodes island".contains(text.as_str()) {
results.push(crate::generated::types::SearchResult::Company(
self::Company,
));
}
if "dublinn".contains(text.as_str()) {
results.push(crate::generated::types::SearchResult::Faction(
self::Faction,
));
}
Ok(results)
}
}

Query each possible result using fragments:

{
search(text: "reed") {
__typename
... on Operator {
codename
}
... on Company {
name
}
... on Faction {
name
}
}
}

Both matching operator records are returned:

{
"data": {
"search": [
{ "__typename": "Operator", "codename": "Reed" },
{ "__typename": "Operator", "codename": "Reed the Flame Shadow" }
]
}
}

Returning SearchResult::Operator, SearchResult::Company, or SearchResult::Faction is what establishes __typename at runtime. A value outside those generated variants cannot be returned as this Union.

The application constructs Context for every request. Use it to protect a nullable personnel field while keeping the public operator data available.

Add the file Object and nullable field:

type PersonnelFile {
title: String!
content: String!
}
type Operator implements Person {
id: ID!
displayName: String!
codename: String!
affiliation: Organization
relationships: [PersonRelationship!]!
files: [PersonnelFile!]
}

Run cargo build, then replace the generated file representation and define the request Context:

pub struct RequestContext {
pub can_read_confidential: bool,
}
#[allow(non_camel_case_types)]
#[derive(Clone, Copy)]
pub struct PersonnelFile {
title: &'static str,
content: &'static str,
}

In src/main.rs, replace the unit Context in the GraphQL handler:

let context = resolvers::RequestContext {
can_read_confidential: false,
};

The other resolvers can remain generic over C: Sync. Operator.files is the exception: replace its entire generated impl block, not only the unimplemented!() body. The generated scaffold accepts a generic C, but this resolver must name RequestContext before it can access can_read_confidential. The highlighted declaration lines make that change:

impl necrassrs::Resolver<crate::generated::fields::Operator::files, RequestContext>
for self::Operator
{
type Output = Option<Vec<self::PersonnelFile>>;
async fn resolve(
&self,
context: &RequestContext,
_args: crate::generated::types::Operator::files::Args,
) -> Result<Self::Output, necrassrs::ResolverError> {
if !context.can_read_confidential {
return Err(
necrassrs::ResolverError::new("Personnel files are confidential")
.with_extension("code", "FORBIDDEN"),
);
}
Ok(Some(vec![self::PersonnelFile {
title: "Personnel summary",
content: operator_name(self.id)
.ok_or_else(|| necrassrs::ResolverError::new("Unknown operator"))?,
}]))
}
}

Complete the two generated leaf resolvers with Ok(self.title.to_owned()) and Ok(self.content.to_owned()) respectively.

With confidential access disabled, run:

{
operator(codename: "Necrass") {
codename
files {
title
content
}
}
}

The files field is nullable, so its resolver error does not discard the successfully completed codename field:

{
"errors": [
{
"message": "Personnel files are confidential",
"locations": [{ "line": 4, "column": 5 }],
"path": ["operator", "files"],
"extensions": { "code": "FORBIDDEN" }
}
],
"data": {
"operator": {
"codename": "Necrass",
"files": null
}
}
}

Set can_read_confidential to true and send the same request to receive the file data instead. Authentication and authorization policy remain application responsibilities; Context only carries the request-specific decision into the resolver.

After all sections, schema/schema.graphql contains:

interface Person {
id: ID!
displayName: String!
}
interface Organization {
id: ID!
name: String!
}
type Operator implements Person {
id: ID!
displayName: String!
codename: String!
affiliation: Organization
relationships: [PersonRelationship!]!
files: [PersonnelFile!]
}
type Company implements Organization {
id: ID!
name: String!
personnel: [Person!]!
}
type Faction implements Organization {
id: ID!
name: String!
members: [Person!]!
}
type PersonRelationship {
kind: RelationshipKind!
person: Person!
}
type PersonnelFile {
title: String!
content: String!
}
enum RelationshipKind {
SIBLING
ALTERNATE_FORM
COOPERATES_WITH
}
union SearchResult = Operator | Company | Faction
type Query {
operator(codename: String!): Operator
personnel: [Person!]!
organizations: [Organization!]!
search(text: String!): [SearchResult!]!
}

Run the final checks from the application directory:

Terminal window
cargo fmt --check
cargo check --locked
cargo run --locked

The completed application demonstrates the distinction between the three composite result contracts:

SDL result Resolver output
Operator Operator or Option<Operator>
Person types::Person<Operator>
Organization types::Organization<Company, Faction>
SearchResult types::SearchResult<Operator, Company, Faction>

An Object value is returned directly. Interface and Union values use generated enums to state which concrete Object is present. The same Object field resolvers then complete the client’s nested selection.