GraphQL development best practices
The following are recommendations for how to design new APIs with the intention of promoting consistent usage.
Use of input types
We strongly recommend using input types to define the expected input, rather than a large number of individual arguments. This makes the schema more readable and maintainable. For example, instead of this:
""" bad example do not do this """ extend type Mutation { core_widget_create_widget( name: String! description: String! type: param_alphaext! """ Potentially many more arguments here """ ): core_widget_widget_result! }
Create a dedicated input type like this:
input core_widget_widget_input { name: String! description: String! type: param_alphaext! """ Potentially many more arguments here """ } extend type Mutation { core_widget_create_widget( widget: core_widget_widget_input ): core_widget_widget_result! }
When authoring the schema, you should strive to define input types that could potentially be reused in multiple queries or mutations (even if they are not at the time of creation). For example, if later you decided to add another mutation which allowed you to create a widget and assign it to an (optional) category at the same time, don't create a new input type like this:
" bad example do not do this " input core_widget_widget_with_category_input { name: String! description: String! type: param_alphaext! category_id: core_id! } extend type Mutation { core_widget_create_widget_in_category( widget: core_widget_widget_with_category_input ): core_widget_widget_result! }
This leads to unnecessary duplication. Instead, using a new argument and reusing the existing input type would make more sense:
input core_widget_widget_input { name: String! description: String! type: param_alphaext! } extend type Mutation { core_widget_create_widget_in_category( widget: core_widget_widget_input category_id: core_id! ): core_widget_widget_result! }
Note: this is just a "toy" example. Another valid approach would be to add category_id as an optional property on the input type and reuse the existing create mutation, but if you were going to have a separate mutation you should follow the second approach in terms of input types.
In other situations, separate input types are unavoidable. For example, perhaps some fields are required during creation, but are optional when updating, and you want to enforce that via the types.
Use of result types
Similar to input types above, our strong preference is to return results via a dedicated result type, rather than using the type itself as the return value. For example, when requesting a set of widgets, instead of returning an array of widget types:
type core_widget_widget { id: core_id! name: String! description: String! type: param_alphaext! } extend type Query { core_widget_my_widgets(): [core_widget_widget!]! }
Instead return a result type that includes the results as a property:
type core_widget_widget { id: core_id! name: String! description: String! type: param_alphaext! } type core_widget_my_widgets_result { items: [core_widget_widget!]! } extend type Query { core_widget_my_widgets(): core_widget_widget_result! }
The advantage of this approach is that if you want to add additional data to the result, you can do so without completely modifying the query. A couple of common ways you might want to extend the result include: adding a status field to indicate whether the operation was successful, or adding pagination to a query that previously didn't have it.
Multiple query endpoints in the same persisted query
Your schema defines query and mutation endpoints, which define how the API is accessed. Separate to that, you define persisted queries, which make use of those endpoints to gather specific data. GraphQL does support querying multiple endpoints within the same persisted query, like this:
query a_component_persisted_query($some_arg: core_id!, $another_arg: core_id!) { a_component_query_endpoint(some_arg: $some_arg) { some_field another_field } optional_alias: another_component_a_different_query_endpoint(another_arg: $another_arg) { example_field } }
This allows you to define one persisted query that returns data from multiple query endpoints in one go.
We encourage using this approach in order to break up query endpoints so that they have a clear single purpose, while still allowing you to efficiently gather multiple pieces of individual information in order to build an end-user experience efficiently. For example, imagine you wanted to build a user 'dashboard' by gathering together a selection of disparate information about a user:
query core_user_dashboard { core_user_profile_info() { username email } mod_perform_user_performance_activity_progress() { num_incomplete_activities num_complete_activities num_total_activities } totara_engage_user_participation() { num_likes num_articles_created num_shares } }
In this case, it makes more sense to define separate query endpoints with each specific purpose than to create one endpoint that pulls together all this data on the back end, as the individual query endpoints are more likely to be reusable for other purposes.
One caveat with this approach is that the execution context of the persisted query can only be set once, so you can't combine queries that are working in different contexts. You may get the error "Context can only be set once per execution" if you are using more than one query endpoint that sets the execution context. We have a ticket to remove this constraint (TL-29637).
If that is a problem, consider doing batch queries via JS, which will still bundle queries in order to avoid multiple requests, but can cope with different execution contexts.
Large complex queries vs multiple smaller queries
Often when rendering a complex page it will be made up of a hierarchy of components, which sometimes might be quite deeply nested. In order to build isolated components, it is often desirable to have individual components make AJAX requests to collect their own data, as this avoids dependencies on higher-level components. The downside of this approach is that a page can end up making a large number of small requests, which can create a poor user experience if the page reflows as it progressively loads data. In some cases, if the page wouldn't be functional without all the data, it might make more sense to do a high-level, complex query to gather all the required data, then pass that data to individual components as needed.
At this stage we don't prescribe a specific approach, other than to encourage the developer to consider the trade-offs of these two approaches, and to ensure that they do adequate and appropriate performance testing with realistic data in a realistic end-user environment, to ensure that the user experience is acceptable. Considerations such as implementing pagination can help to ensure the maximum data to generate for any given page is not excessive.
For complex pages, we sometimes use Xstate to provide better support for handling this situation - for example, by allowing child components to declare the resources they need in a way that the data can be gathered centrally without tight coupling of components.
Handling zero results
When returning a set of items you would typically return an empty array (rather than null) if no items were found, and this would be interpreted and handled via the front end. Potentially, if you have a situation where you know there must be at least one result, you might want to throw an error instead, but we do not have any specific recommendations other than to ensure the front end can handle whatever the back end chooses to provide.
In the case of a single item, no result could indicate an error (the item doesn't exist), permissions issues (it does exist but the user doesn't have permission to see it), a malformed request, or no problem. For security reasons, we typically provide the same response for 'not found' and 'no permissions', but other than that we don't provide specific recommendations on how to handle this situation other than to ensure the front end can handle whatever the back end chooses to provide.
Pagination
We recommend cursor-based pagination using the core_pagination_input input type and core_pagable_result interface. See Pagination with GraphQL for more information.
Filtering
When filtering a results set, a common pattern is to use an input type with optional properties for each of the ways the data can be filtered. This is passed to the query as an argument, and can then be processed by the query resolver, allowing the results to be optionally filtered in one or more ways simultaneously. For example:
enum mod_perform_subject_instance_about_filter { SELF OTHERS } input mod_perform_subject_instance_filters { about: [mod_perform_subject_instance_about_filter!]! activity_type: [core_id!] overdue: param_boolean } extend type Query { """ A list of all performance activities the current user is participating in. """ mod_perform_my_subject_instances( filters: mod_perform_subject_instance_filters pagination: core_pagination_input ): mod_perform_subject_sections_page! }
Naming of persisted queries vs query endpoints/resolvers
When creating a persisted query there are two different names: the name of the persisted query and the name of the query endpoint that the persisted query is referencing. These can look very similar (or be identical), but it's important to understand the distinction and name them correctly.
query mod_example_persisted_query_name { mod_example_query_endpoint_name { some_field another_field } }
The name of the persisted query must match the location of the persisted query in the source code. The name of the query endpoint must match the location of the query resolver in the source code.
In some cases you are creating a new query endpoint for a single purpose, and you then need to create a persisted query to make use of it. In these cases, it might make sense to name both the same thing, and that is perfectly fine.
However, in some cases you might have two separate persisted queries, used in different spots for different reasons that use the same query endpoint. In that case, the persisted queries should reference their intended purpose.
For example:
""" This persisted query may be used to display a small amount of user data in a summary block """ query core_user_get_basic_user_info { this_is_an_alias: core_user_get_user_info { id email username } }
In general, we only recommend doing this if you have a reason for why you want the output to be structured differently, otherwise just let it return using the query endpoint name. One example of why this might be desirable is that the mobile app prefers to receive keys with camel case rather than snake case, so it can use aliases to make sure it receives the data keyed appropriately.
Formatters
It is common to want to expose information about an existing model object via GraphQL. To make this straightforward while also supporting different ways of formatting the output, we recommend using formatters.
Middleware
Often you have a common pattern that you want to apply to a number of query endpoints (for example requiring login, or requiring a valid item ID be provided to the resolver and preloading that item to check it is valid). Rather than duplicating the code in every query resolver, we recommend making use of middleware.
Documenting schema with comments
In GraphQL, comments made in the schema can be extracted and displayed via introspection. It is common for GraphQL clients to make documentation available based on the comments against the schema. Therefore please try to include useful comments inline, as it will make things a lot easier for the user of the API.
See Extending API documentation for technical information about comments, and GraphQL documentation authoring best practices for information on how to write comments.