@ttoss/graphql-api
This package offers an opinionated approach to building a GraphQL API using the ttoss ecosystem modules. It is designed to provide a resilient and scalable solution for creating complex GraphQL APIs while focusing on the following goals:
-
Modular Design:
Build your GraphQL API using modules to simplify and organize the development of large, complex APIs. -
Relay Compatibility:
As Relay is the primary GraphQL client in the ttoss ecosystem, this package implements the Relay Server Specification for seamless client-server interaction. -
Schema Building:
Generate the GraphQL schema required for Relay's introspection queries with @ttoss/graphql-api-cli. -
TypeScript Types Generation: Automatically generate TypeScript types for your GraphQL schema with @ttoss/graphql-api-cli.
-
AWS AppSync Support: Create GraphQL APIs compatible with AWS AppSync. Additionally, this package includes support for running a local GraphQL API server for development and testing purposes.
Installation
pnpm add @ttoss/graphql-api graphql
Usage
This library uses graphql-compose to create the GraphQL schema. It re-exports all the graphql-compose types and methods, so you can use it directly from this package.
Type Creation
For more examples about how to create types, check the graphql-compose documentation.
import { schemaComposer } from '@ttoss/graphql-api';
const UserTC = schemaComposer.createObjectTC({
name: 'User',
fields: {
id: 'ID!',
name: 'String!',
},
});
This library uses the tsconfig.json file from the target package it is being applied on. If you are using relative imports in your package you can skip this section, but, if you use path aliases in your typescript code by leveraging the paths property, the baseUrl must be filled accordingly. This is needed because in order to interpret the path aliases, ts-node uses tsconfig-paths to resolve the modules that uses this config, and tsconfig-paths needs both baseUrl and paths values to be non-null. A tsconfig.json example that follows such recommendations is given below:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["src/*"]
}
}
}
Resolvers
Creating Resolvers
Resolvers are functions that resolve a value for a type or field in your schema. Here’s a simple example of how to create a resolver for the User type:
UserTC.addResolver({
name: 'findById',
type: UserTC,
args: {
id: 'String!',
},
resolve: async ({ args }) => {
// Implement your logic to find a user by ID
return await findUserById(args.id);
},
});
Integrate All Modules
Once you've created all your types and resolvers, you can integrate all the modules to create the GraphQL schema.
// scr/schemaComposer.ts
import { schemaComposer } from '@ttoss/graphql-api';
import './modules/Module1/composer';
import './modules/Module3/composer';
import './modules/User/composer';
export { schemaComposer };
Recommended Module Structure
As your GraphQL API grows, you'll encounter three recurring problems: circular imports between modules, relational fields hidden in resolver files (making TC files incomplete), and monolith resolver files with hundreds of lines. The structure below solves all three.
Directory Structure
Each module has TC files at the root and a resolvers/ folder with one file per GraphQL field:
src/modules/
├── Meta/
│ ├── index.ts # side-effect imports of all resolver files
│ ├── MetaAdAccountTC.ts # pure shape — scalars + string type refs
│ ├── MetaCampaignTC.ts
│ └── resolvers/
│ ├── Query.metaAdAccount.ts # root query
│ ├── Mutation.addMetaAdAccounts.ts # root mutation
│ ├── MetaAdAccount.optimizations.ts # cross-module relational field
│ └── MetaCampaign.optimization.ts
├── Optimizations/
│ ├── index.ts
│ ├── OptimizationTC.ts
│ └── resolvers/
│ ├── Query.optimization.ts
│ ├── Mutation.createOptimization.ts
│ ├── Optimization.metaCampaign.ts # cross-module relational field
│ └── Optimization.algorithm.ts
└── User/
├── index.ts
├── UserTC.ts
└── resolvers/
└── Query.me.ts
Rule 1: TC Files — All Fields Declared with String Type References
TC files declare all fields (scalars and relational) but use string type names for cross-module types instead of importing other TCs. schemaComposer resolves string type names lazily when building the schema, eliminating circular imports and keeping the full type shape visible in one file.
// MetaAdAccountTC.ts — no imports from other modules
import { schemaComposer } from '@ttoss/graphql-api';
export const MetaAdAccountTC = schemaComposer.createObjectTC({
name: 'MetaAdAccount',
fields: {
id: 'ID!',
name: 'String!',
// String type ref — resolved lazily by schemaComposer at build time
optimizations: '[Optimization!]!',
tracking: 'MetaAdAccountTracking',
},
});
Rule 2: Resolver Files — One Field Per File with extendField
Each resolver file attaches the resolve function to a single field using extendField. Root queries and mutations use schemaComposer.Query.addFields() / schemaComposer.Mutation.addFields().
// resolvers/MetaAdAccount.optimizations.ts
import { schemaComposer } from '@ttoss/graphql-api';
import { MetaAdAccountTC } from '../MetaAdAccountTC';
MetaAdAccountTC.extendField('optimizations', {
args: { isActive: 'Boolean' },
resolve: async (source, args) => {
return findManyOptimizations({
metaAdAccountId: source.id,
isActive: args.isActive,
});
},
});
// resolvers/Query.metaAdAccount.ts
import { schemaComposer } from '@ttoss/graphql-api';
schemaComposer.Query.addFields({
metaAdAccount: {
type: 'MetaAdAccount',
args: { id: 'ID!' },
resolve: async (_source, args) => {
return findMetaAdAccount({ id: args.id });
},
},
});
Rule 3: Dot Notation File Naming
Resolver files use Parent.field.ts naming that maps 1:1 to the GraphQL schema path:
| File name | GraphQL path |
|---|---|
Query.metaAdAccount.ts | metaAdAccount field on Query |
Mutation.createOptimization.ts | createOptimization field on Mutation |
MetaAdAccount.optimizations.ts | optimizations field on MetaAdAccount |
Rule 4: Index Files — Side-Effect Imports
Each module's index.ts imports all TC and resolver files as side effects, guaranteeing registration order:
// Meta/index.ts
import './MetaAdAccountTC';
import './MetaCampaignTC';
import './resolvers/Query.metaAdAccount';
import './resolvers/Mutation.addMetaAdAccounts';
import './resolvers/MetaAdAccount.optimizations';
import './resolvers/MetaCampaign.optimization';
The top-level schemaComposer.ts then imports each module index:
// src/schemaComposer.ts
import { schemaComposer } from '@ttoss/graphql-api';
import './modules/Meta';
import './modules/Optimizations';
import './modules/User';
export { schemaComposer };
Why This Works
| Concern | Solution |
|---|---|
| No circular imports | TC files never import other module TCs. Only resolver files (leaf nodes) do cross-module imports, and nothing imports from them. |
| Full shape visibility | The TC file shows every field on the type — scalars and relational — in one place. |
| Import order independence | String type refs are resolved lazily at buildSchema() time. |
| Easy to locate | Parent.field.ts maps 1:1 to the GraphQL schema path. |
| One responsibility per file | Each resolver file handles exactly one field, keeping files small and focused. |
Recommended Test Structure
Test files mirror the source layout using the same dot notation — one test file per resolver.
Test Directory Structure
tests/unit/
├── Meta/
│ ├── Query.metaAdAccount.test.ts
│ ├── Query.metaAdAccounts.test.ts
│ ├── Mutation.addMetaAdAccounts.test.ts
│ ├── MetaAdAccount.optimizations.test.ts
│ └── MetaCampaign.optimization.test.ts
├── Optimizations/
│ ├── Query.optimization.test.ts
│ ├── Mutation.createOptimization.test.ts
│ └── Optimization.metaCampaign.test.ts
└── User/
└── Query.me.test.ts
Test Structure Rules
- Same dot notation as resolver files —
Parent.field.test.ts - Module subfolder —
tests/unit/{Module}/mirrorssrc/modules/{Module}/resolvers/ - No
resolvers/subfolder in tests — unnecessary nesting since test files are already leaf nodes - One test file per resolver — keeps tests focused and easy to find
Testing Style
Prefer schema-level testing using graphql() against schemaComposer.buildSchema() — this validates the full wiring (TC shape → resolver → response):
import { graphql } from 'graphql';
import { schemaComposer } from 'src/schemaComposer';
test('should return optimizations for a MetaAdAccount', async () => {
const response = await graphql({
schema: schemaComposer.buildSchema(),
source: /* GraphQL */ `
query ($id: ID!) {
node(id: $id) {
... on MetaAdAccount {
optimizations {
id
isActive
}
}
}
}
`,
contextValue: mockContext,
variableValues: { id: globalId },
});
expect(response.errors).toBeUndefined();
expect(response.data).toEqual({
/* expected */
});
});
Source-to-Test Mapping
| Source file | Test file |
|---|---|
src/modules/Meta/resolvers/Query.metaAdAccount.ts | tests/unit/Meta/Query.metaAdAccount.test.ts |
src/modules/Meta/resolvers/MetaAdAccount.optimizations.ts | tests/unit/Meta/MetaAdAccount.optimizations.test.ts |
src/modules/Optimizations/resolvers/Mutation.createOptimization.ts | tests/unit/Optimizations/Mutation.createOptimization.test.ts |
This 1:1 mapping makes it trivial to find the test for any resolver and vice versa.
Relay Server Specification
As ttoss uses Relay as the main GraphQL client, this library implements the Relay Server Specification.
Object Identification
Method composeWithRelay will handle the object identification for your ObjectTypeComposer, it will return a globally unique ID among all types in the following format base64(TypeName + ':' + recordId).
Method composeWithRelay only works if ObjectTypeComposer meets the following requirements:
-
Has defined
recordIdFn: returns the id for theglobalIdconstruction. For example, if you use DynamoDB, you could create id from hash and range keys:UserTC.setRecordIdFn((source) => {
return `${source.hashKey}:${source.rangeKey}`;
}); -
Have
findByIdresolver: this resolver will be used byRootQuery.nodeto resolve the object byglobalId. Also, add the__typenamefield is required by Relay to know the type of the object to thenodefield works. For example:UserTC.addResolver({
name: 'findById',
type: UserTC,
args: {
id: 'String!',
},
resolve: ({ args }) => {
const { type, recordId } = fromGlobalId(args.id);
// find object
},
}); -
Call
composeWithRelaymethod: this will add theidfield and thenodequery. For example:composeWithRelay(UserTC);
Example
import {
composeWithRelay,
schemaComposer,
fromGlobalId,
} from '@ttoss/graphql-api';
const UserTC = schemaComposer.createObjectTC({
name: 'User',
fields: {
id: 'ID!',
name: 'String!',
},
});
/**
* 1. Returns you id for the globalId construction.
*/
UserTC.setRecordIdFn((source) => {
/**
* If you use DynamoDB, you could create id from hash and range keys:
* return `${source.hashKey}:${source.rangeKey}`;
*/
return source.id;
});
/**
* 2. Define `findById` resolver (that will be used by `RootQuery.node`).
*/
UserTC.addResolver({
name: 'findById',
type: UserTC,
args: {
id: 'String!',
},
resolve: async ({ args }) => {
const { type, recordId } = fromGlobalId(args.id);
const user = await query({ id: recordId });
return {
...user,
__typename: UserTC.getTypeName(), // or 'User';
};
},
});
/**
* 3. This will add the `id` field and the `node` query.
*/
composeWithRelay(UserTC);
We inspired ourselves on graphql-compose-relay to create composeWithRelay.
What Is a Node in Relay
In the Relay Server Specification, a Node is any object that:
Has a globally unique identity across your entire GraphQL API.
Can be fetched independently using the node(id: ID!): Node query.
Implements the Node interface.
Only types that are nodes should use composeWithRelay.
When to Use composeWithRelay (i.e., When Is a Type a Node?)
| Condition | Must Be True |
|---|---|
| Has a unique record ID in your database or external system | Yes |
| Can be refetched by ID from anywhere in the app | Yes |
| Clients may cache and reuse it via global ID | Yes |
| Appears nested inside other objects and also stands alone | Yes |
Not Nodes (Don't Use composeWithRelay):
Examples of types should never use composeWithRelay
// Wrong - embedded objects
SubscriptionUpcomingInvoiceTC; // Only exists inside Subscription
PriceDetailsTC; // Embedded value object
MarketingFeatures; // Configuration data
// Wrong - computed values
UserFullNameTC; // Derived from firstName + lastName
OrderTotalTC; // Calculated from line items
// Wrong - transient data
ValidationErrorTC; // Temporary error state
SearchResultTC; // Query response wrapper
Why Embedded Objects Are Not Nodes
Consider this embedded object:
type Subscription {
upcomingInvoice: SubscriptionUpcomingInvoice
}
SubscriptionUpcomingInvoice should not use composeWithRelay because it fails the Node criteria:
Missing Node Requirements:
- No independent identity - exists only within its parent Subscription
- Cannot be refetched independently - no direct database record
- Not cached separately - lifecycle tied 1:1 to parent object
- Never queried directly - accessed only through parent
Problems Created by composeWithRelay:
- Generates meaningless global IDs for non-independent objects
- Pollutes the
nodeinterface with unfetchable objects - Breaks Relay's cache normalization expectations
- Adds unnecessary complexity without any benefit
Simple Test: If you cannot write node(id: "...") to fetch this object independently, it should not be a Node.
Connections
This package provides the method composeWithConnection to create a connection type and queries for a given type, based on graphql-compose-connection plugin and following the Relay Connection Specification.
import { composeWithConnection } from '@ttoss/graphql-api';
AuthorTC.addResolver({
name: 'findMany',
type: AuthorTC,
resolve: async ({ args }) => {
// find many
},
});
composeWithConnection(AuthorTC, {
findManyResolver: AuthorTC.getResolver('findMany'),
countResolver: AuthorTC.getResolver('count'),
sort: {
ASC: {
value: {
scanIndexForward: true,
},
cursorFields: ['id'],
beforeCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$lt = cursorData.id;
},
afterCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$gt = cursorData.id;
},
},
DESC: {
value: {
scanIndexForward: false,
},
cursorFields: ['id'],
beforeCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$gt = cursorData.id;
},
afterCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$lt = cursorData.id;
},
},
},
});
schemaComposer.Query.addFields({
authors: Authors.getResolver('connection'),
});
When you composeWithConnection a type composer, it will add the resolver connection to the type composer, so you can add to Query or any other type composer. For example:
schemaComposer.Query.addFields({
authors: Authors.getResolver('connection'),
});
The resolver connection has the following arguments based on the Relay Connection Specification:
-
first: the number of nodes to return. -
after: the cursor to start the query. -
last: the number of nodes to return. -
before: the cursor to start the query. -
limit: the limit of nodes to return. It's thefirstorlastargument plus one. It's used to know if there are more nodes to return to sethasNextPageorhasPreviousPagePageInfo fields. For example, iffirstis10,limitwill be11. If the resolver returns11nodes, the resolver will return10but it knows there are more nodes to return, sohasNextPagewill betrue. -
skip: it's thecountminuslast. It only works on backward pagination. -
sort: the sort option to use. It's thevalueof thesortobject. In our example, it's{ scanIndexForward: true }forASCand{ scanIndexForward: false }, forDESC. -
filter: the filter to use. It'll exist if you add thefiltertofindManyResolverfor example, the implementation below will add thefilterargument with thenameandbookfields:AuthorTC.addResolver({
name: 'findMany',
type: AuthorTC,
args: {
filter: {
name: 'String',
book: 'String',
},
},
resolve: async ({ args }) => {
// find many
},
});
ConnectionArgs type
To type a connection resolver in TypeScript, import the generic ConnectionArgs<TFilter, TSort> helper. It captures all of the standard pagination arguments (first, after, last, before, limit, skip, sort, filter) and lets you specify only the connection-specific parts:
import {
type ConnectionArgs,
type ResolverResolveParams,
} from '@ttoss/graphql-api';
type NotificationFilter = {
isRead?: boolean | null;
kind?: string | null;
};
NotificationTC.addResolver({
name: 'findMany',
type: [NotificationTC.NonNull],
resolve: async ({
args,
}: ResolverResolveParams<
unknown,
ResolverContext,
ConnectionArgs<NotificationFilter>
>) => {
return findManyNotifications({
first: args.first,
after: args.after,
filter: args.filter,
});
},
});
To configure composeWithConnection, you need to provide the following options:
findManyResolver
The resolver that will be used to find the nodes. It receives the following arguments:
-
args: theargsobject from the resolver. Example:AuthorTC.addResolver({
name: 'findMany',
type: AuthorTC,
args: {
filter: {
name: 'String',
book: 'String',
},
// other args
},
resolve: async ({ args }) => {
// find many
},
}); -
rawQuery: an object created bybeforeCursorQueryorafterCursorQuerymethods from sort option.
countResolver
The resolver that will be used to count the nodes.
sort
It's an object that defines the sort options. Each key is the sort name and the value is an object with the following properties:
-
value: and object that theargsresolver will receive as thesortargument. It'll also be the values of the sort enum composer created (check the implementation details here.) -
cursorFields: an array of fields that will be used to create the cursor. -
beforeCursorQueryandafterCursorQuery: methods that will be used to create therawQueryobject for thefindManyResolver. They receive the following arguments:rawQuery: therawQueryobject that will be used to find the nodes.cursorData: the data from the cursor define oncursorFields. For example, if you definecursorFieldsas['id', 'name'], thecursorDatawill an object with theidandnameproperties.resolveParams: theresolveParamsobject from the resolver. You can accessargs,contextandinfoand other GraphQL properties from this object.
Example:
composeWithConnection(AuthorTC, {
// ...
sort: {
ASC: {
// ...
cursorFields: ['id', 'name'],
// Called when `before` cursor is provided.
beforeCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$lt = cursorData.id;
rawQuery.name.$lt = cursorData.name;
},
// Called when `after` cursor is provided.
afterCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$gt = cursorData.id;
rawQuery.name.$gt = cursorData.name;
},
},
},
});In the example above, the
findManyResolverwill receive the followingrawQueryobject whenbeforecursor is provided:{
"id": {
"$lt": "id-from-cursor"
},
"name": {
"$lt": "name-from-cursor"
}
}
Middlewares
This package provides a way to add middlewares to your final schema. You can add middlewares compatible with graphql-middleware by passing them to the middlewares option on buildSchema method. For example, you can use GraphQL Shield to add authorization to your API:
import { buildSchema } from '@ttoss/graphql-api';
import { allow, deny, shield } from '@ttoss/graphql-api/shield';
import { schemaComposer } from './schemaComposer';
const NotAuthorizedError = new Error('Not authorized!');
/**
* The error name is the same value `errorType` on GraphQL errors response.
*/
NotAuthorizedError.name = 'NotAuthorizedError';
const permissions = shield(
{
Query: {
'*': deny,
author: allow,
},
Author: {
id: allow,
name: allow,
},
},
{
fallbackRule: deny,
fallbackError: NotAuthorizedError,
}
);
/**
* Apply middlewares to all resolvers.
*/
const logInput = async (resolve, source, args, context, info) => {
console.log(`1. logInput: ${JSON.stringify(args)}`)
const result = await resolve(source, args, context, info)
console.log(`5. logInput`)
return result
}
/**
* Apply middlewares only to a specific resolver.
*/
const logOnQueryMe = {
Query: {
me: logInput
}
}
const schema = buildSchema({
schemaComposer,
middlewares; [permissions, logInput, logOnQueryMe],
})
Shield
This package re-exports the all methods from GraphQL Shield.
import { allow, deny, shield } from '@ttoss/graphql-api/shield';
Building Schema and Types
Check @ttoss/graphql-api-cli for more information about how to build the schema and types.
How to Create Tests
We recommend testing the whole GraphQL API using the graphql object and the schema composer to provide the schema. For example:
import { graphql } from 'graphql';
import { schemaComposer } from './schemaComposer';
test('testing my query', () => {
const author = {
id: '1',
name: 'John Doe',
};
const response = await graphql({
schema: schemaComposer.buildSchema(),
source: /* GraphQL */ `
query ($id: ID!) {
node(id: $id) {
id
... on Author {
name
}
}
}
`,
variableValues: {
id: author.id,
},
});
expect(response).toEqual({
data: {
node: {
id: author.id,
name: author.name,
},
},
});
});