Using Spring for GraphQL With Spring Data Neo4j


A few weeks ago, version 1.2.0 of Spring for GraphQL was released with a bunch of new features. This also includes even better integration with Spring Data modules. Motivated by those changes, more support in Spring Data Neo4j has been added, to give the best experience when using it in combination with Spring GraphQL. This post will guide you on creating a Spring application with data stored in Neo4j and GraphQL support. If you are only partially interested in the domain, you can happily skip the next section 😉

Domain

For this example, I have chosen to reach into the Fediverse. More concrete, to put some Servers and User into the focus. Why the domain was picked up for this, is now for the reader to discover in the following paragraphs.

The data itself is aligned to the properties that could be fetched from the Mastodon API. To keep the dataset simple, the data was created by hand instead of fetching everything. This results in an easier-to-inspect dataset. The Cypher import statements look like this:

CREATE (s1:Server {
uri:'mastodon.social', title:'Mastodon', registrations:true,
short_description:'The original server operated by the Mastodon gGmbH non-profit'})

CREATE (meistermeier:Account {id:'106403780371229004', username:'meistermeier', display_name:'Gerrit Meier'})
CREATE (rotnroll666:Account {id:'109258442039743198', username:'rotnroll666', display_name:'Michael Simons'})

CREATE
(meistermeier)-[:REGISTERED_ON]->(s1),
(rotnroll666)-[:REGISTERED_ON]->(s1)

CREATE (s2:Server {
uri:'chaos.social', title:'chaos.social', registrations:false,
short_description:'chaos.social – a Fediverse instance for & by the Chaos community'})

CREATE (odrotbohm:Account {id:'108194553063501090', username:'odrotbohm', display_name:'Oliver Drotbohm'})

CREATE
(odrotbohm)-[:REGISTERED_ON]->(s2)

CREATE
(odrotbohm)-[:FOLLOWS]->(rotnroll666),
(odrotbohm)-[:FOLLOWS]->(meistermeier),
(meistermeier)-[:FOLLOWS]->(rotnroll666),
(meistermeier)-[:FOLLOWS]->(odrotbohm),
(rotnroll666)-[:FOLLOWS]->(meistermeier),
(rotnroll666)-[:FOLLOWS]->(odrotbohm)

CREATE
(s1)-[:CONNECTED_TO]->(s2)

After running the statement, the graph forms this shape.

Graph view of data set

Noticeable information is that even though all users follow each other, the Mastodon servers are only connected in one direction. Users on server chaos.social cannot search or explore timelines on mastodon.social.

Disclaimer: The federation of the servers is made up of a non-bidirectional relationship, for this example.

Components

To follow along with the example shown, you should use the following minimum versions:

  • Spring Boot 3.1.1 (which includes the following)
  • Spring Data Neo4j 7.1.1
  • Spring GraphQL 1.2.1
  • Neo4j version 5

The best is to head over to https://start.spring.io and create a new project with Spring Data Neo4j and Spring GraphQL dependency. If you are a little bit lazy, you can also download the empty project from this link.

Spring initializr page with project configuration

To follow the example 100%, you would need to have Docker installed on your system. If you don’t have this option or don’t want to use Docker, you could use either Neo4j Desktop or the plain Neo4j Server artifact for local deployment, or as hosted options, Neo4j Aura or an empty Neo4j Sandbox.

First Spring for GraphQL Steps

In this example, the heavy lifting of configuration will be done by the Spring Boot autoconfiguration. There is no need to set up the beans manually. To find out more about what happens behind the scenes, please have a look at the Spring for GraphQL documentation. Later, specific sections of the documentation will be referenced.

Entity and Spring Data Neo4j Setup

First thing to do is to model the domain classes. As already seen in the import, there are only Servers and Accounts.

@Node
public class Account {
@Id
private final String id; // (1)
private final String username;
@Property("display_name")
private final String displayName; // (2)
@Relationship("REGISTERED_ON")
private final Server server;
@Relationship("FOLLOWS")
private List<Account> following;
// constructor, etc.
}
  1. Given Mastodon’s id strategy for user ids, it is valid to assume, that the id is (server) unique.
  2. Here and a few lines below in the Server, @Property is used to map the database field display_name to camel-case displayName in the Java entity.
@Node
public class Server {
@Id
private final String uri; // (1)
private final String title;
@Property("registrations")
private final Boolean registrationsAllowed;
@Property("short_description")
private final String shortDescription;
@Relationship("CONNECTED_TO")
private List<Server> connectedServers;
// constructor, etc.
}

With these entity classes, a AccountRepository can be created.

@GraphQlRepository // (1)
public interface AccountRepository extends Neo4jRepository<Account, String>
{
}
  1. Details why this annotation is used will follow later. Here for completeness of the interface.

To connect to the Neo4j instance, the connection parameters need to be added to the application.properties file.

spring.neo4j.uri=neo4j://localhost:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=verysecret

If not happened yet, the database can be started, and the Cypher statement from above run to set up the data. In a later part of this article, Neo4j-Migrations will get used to being sure that the database is always in the desired state.

Spring for GraphQL Setup

Before looking into the integration features of Spring Data and Spring for GraphQL, the application will get set up with a @Controller stereotype annotated class. The controller will get registered by Spring for GraphQL as a DataFetcher for the query accounts.

@Controller
public class AccountController {
private final AccountRepository repository;
@Autowired
public AccountController(AccountRepository repository) {
this.repository = repository;
}

@QueryMapping
public List<Account> accounts() {
return repository.findAll();
}
}

Defining a GraphQL schema, that defines not only our entities but also the query with the same name as the method in the controller (accounts).

type Query {
accounts: [Account]!
}
type Account {
id: ID!
username: String!
displayName: String!
server: Server!
following: [Account]
lastMessage: String!
}
type Server {
uri: ID!
title: String!
shortDescription: String!
connectedServers: [Server]
}

Also, to browse the GraphQL data in an easy way, GraphiQL should be enabled in the application.properties. This is a helpful tool during development time. Usually, this should be disabled for production deployment.

spring.graphql.graphiql.enabled=true

First Run

If everything is set up as described above, the application can be started with ./mvnw spring-boot:run. Browsing to http://localhost:8080/graphiql?path=/graphql will present the GraphiQL explorer.

Querying in GraphiQL

To verify that the accounts method is working, a GraphQL request is sent to the application.

{
accounts {
username
}
}

and the expected answer gets returned from the server.

{
"data": {
"accounts": [
{
"username": "meistermeier"
},
{
"username": "rotnroll666"
},
{
"username": "odrotbohm"
}
]
}
}

Of course, the method in the controller can be tweaked by adding parameters for respecting arguments with @Argument or getting the requested fields (here accounts.username) to squeeze down the amount of data that gets transported over the network. In the previous example, the repository will fetch all properties for the given domain entity, including all relationships. This data will get mostly discarded to return only the username to the user.

This example should give an impression of what can be done with Annotated Controllers. Adding the query generation and mapping capabilities of Spring Data Neo4j, a (simple) GraphQL application was created.

But at this point both libraries seem to live in parallel in this application and not yet like an integration. How can SDN and Spring for GraphQL get really combined?

Spring Data Neo4j GraphQL integration

As a first step, the accounts method from the AccountController can be deleted. Restarting the application and querying it again with the request from above will still bring up the same result.

This works because Spring for GraphQL recognizes the result type (array of) Account from the GraphQL schema. It scans for eligible Spring Data repositories that match the type. Those repositories have to extend the QueryByExampleExecutor or QuerydslPredicateExecutor (not part of this blog post) for the given type. In this example, the AccountRepository is already implicitly marked as QueryByExampleExecutor because it is extending the Neo4jRepository, that is already defining the executor. The @GraphQlRepository annotation makes Spring for GraphQL aware that this repository can and should be used for the queries, if possible.

Without any changes to the actual code, a second Query field can be defined in the schema. This time it should filter the results by username. A username looks unique at first glance but in the Fediverse this is only true for a given instance. Multiple instances could have the very same usernames in place. To respect this behaviour, the query should be able to return an array of Accounts.

The documentation about query by example (Spring Data Commons) provides more details about the inner workings of this mechanism.

type Query {
account(username: String!): [Account]!
}

Restarting the app will now present the option to add a username interactively as a parameter to the query.

{
account(username: "meistermeier") {
username
following {
username
server {
uri
}
}
}
}

Obviously, there is only one Account with this username.

{
"data": {
"account": [
{
"username": "meistermeier",
"following": [
{
"username": "rotnroll666",
"server": {
"uri": "mastodon.social"
}
},
{
"username": "odrotbohm",
"server": {
"uri": "chaos.social"
}
}
]
}
]
}
}

Behind the scenes, Spring for GraphQL adds the field as a parameter to the object that gets passed to the repositories as an example. Spring Data Neo4j then inspects the example and creates matching conditions for the Cypher query, executes it, and sends the result back to Spring GraphQL for further processing to shape the result into the right response format.

(schematic) API call flow

Pagination

Although the example dataset is not this huge, it’s often useful to have a proper functionality in place that allows one to request the resulting data in chunks. Spring for GraphQL uses the Cursor Connections specification.

A complete schema specification with all types looks like this.

type Query {
accountScroll(first: Int, after: String, last: Int, before:String): AccountConnection
}
type AccountConnection {
edges: [AccountEdge]!
pageInfo: PageInfo!
}
type AccountEdge {
node: Account!
cursor: String!
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String
endCursor: String
}
type Account {
id: ID!
username: String!
displayName: String!
server: Server!
following: [Account]
lastMessage: String!
}
type Server {
uri: ID!
title: String!
shortDescription: String!
connectedServers: [Server]
}

Even though I personally like to have a completely valid schema, it is possible to skip all the Cursor Connections specific parts in the definition. Just the query with the AccountConnection definition is sufficient for Spring for GraphQL to derive and fill in the missing bits. The parameters read as following:

  • first: the amount of data to fetch if there is no default
  • after: scroll position after the data should be fetched
  • last: the amount of data to fetch before the before position
  • before: scroll position until (exclusive) the data should be fetched

One question remains: In which order is the result set returned? A stable sort order is a must in this scenario. Otherwise, there is no guarantee that the database will return the data in a predictable order. The repository needs also to extend the QueryByExampleDataFetcher.QueryByExampleBuilderCustomizer and implement the customize method. In there, it is also possible to add the default limit for the query. In this case 1 to show the pagination in action.

@GraphQlRepository
public interface AccountRepository extends Neo4jRepository<Account, String>,
QueryByExampleDataFetcher.QueryByExampleBuilderCustomizer<Account>
{
@Override
default QueryByExampleDataFetcher.Builder<Account, ?> customize(QueryByExampleDataFetcher.Builder<Account, ?> builder) {
return builder.sortBy(Sort.by("username"))
.defaultScrollSubrange(new ScrollSubrange(ScrollPosition.offset(), 1, true));
}
}

After the application has restarted, it is now possible to call the first pagination query.

{
accountScroll {
edges {
node {
username
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

To get also the metadata for further interaction, some parts of the pageInfo got also requested.

{
"data": {
"accountScroll": {
"edges": [
{
"node": {
"username": "meistermeier"
}
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "T18x"
}
}
}
}

Now the endCursor can be used for the next interaction. Querying the application with this as the value for after and with a limit of 2…

{
accountScroll(after:"T18x", first: 2) {
edges {
node {
username
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

…​results in the last element(s). Also, the marker that there is no next page (hasNextPage=false) indicates that the pagination reached the end of the data set.

{
"data": {
"accountScroll": {
"edges": [
{
"node": {
"username": "odrotbohm"
}
},
{
"node": {
"username": "rotnroll666"
}
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "T18z"
}
}
}
}

It is also possible to scroll through the data backward by using the defined last and before parameters. Also, it is completely valid to combine this scrolling with the already known features of a query by example and define a query in the GraphQL schema that also accepts fields of the Account as filter criteria.

accountScroll(username:String, first: Int, after: String, last: Int, before:String): AccountConnection

Let’s federate

One of the big advantages of using GraphQL is the option to introduce federated data.

Introduction to Apollo Federation

In a nutshell, this means that data stored, e.g., in the database of the application, can be enriched, like in this case, with data from a remote system/microservice/<you name it>. In the end, the data will get presented unified via the GraphQL surface as one entity. The consumer should not need to care that multiple systems were involved in assembling this result.

This data federation can be implemented by making use of the already-defined controller.

@Controller
public class AccountController {

@SchemaMapping
public String lastMessage(Account account) {
var id = account.getId();
String serverUri = account.getServer().getUri();
WebClient webClient = WebClient.builder()
.baseUrl("https://" + serverUri)
.build();
return webClient.get()
.uri("/api/v1/accounts/{id}/statuses?limit=1", id)
.exchangeToMono(clientResponse ->
clientResponse.statusCode().equals(HttpStatus.OK)
? clientResponse
.bodyToMono(String.class)
.map(AccountController::extractData)
: Mono.just("could not retrieve last status")
)
.block();
}
}

Adding the field lastMessage to the Account in the schema, and restarting the application, gives now the option to query for the accounts with this additional information.

{
accounts {
username
lastMessage
}
}

Response with federated data

{
"data": {
"accounts": [
{
"username": "meistermeier",
"lastMessage": "@taseroth erst einmal schauen, ob auf die Aussage auch Taten folgen ;)"
},
{
"username": "odrotbohm",
"lastMessage": "Some #jMoleculesp/#SpringCLI integration cooking to easily add the former[...]"
},
{
"username": "rotnroll666",
"lastMessage": "Werd aber das Rad im Rückwärts-Turbo schon irgendwie vermissen."
}
]
}
}

Looking at the controller again, it becomes clear that the retrieval of the data is quite a bottleneck right now. For every Account, a request gets issued after another. But Spring for GraphQL helps to improve the situation of the ordered requests for each Account after another. The solution is to use @BatchMapping in contrast to @SchemaMapping.

@Controller
public class AccountController {
@BatchMapping
public Flux<String> lastMessage(List<Account> accounts) {
return Flux.concat(
accounts.stream().map(account -> {
var id = account.getId();
String serverUri = account.getServer().getUri();
WebClient webClient = WebClient.builder()
.baseUrl("https://" + serverUri)
.build();

return webClient.get()
.uri("/api/v1/accounts/{id}/statuses?limit=1", id)
.exchangeToMono(clientResponse ->
clientResponse.statusCode().equals(HttpStatus.OK)
? clientResponse
.bodyToMono(String.class)
.map(AccountController::extractData)
: Mono.just("could not retrieve last status")
);
}).toList());
}
}

To improve this situation even more, it is recommended to also introduce proper caching to the result. It might not be necessary that the federated data is fetched on every request but only refreshed after a certain period.

Testing and Test Data

Neo4j-Migrations

Neo4j-Migrations is a project that applies migrations to Neo4j. To be sure that always a clean state of the data is present in the database, an initial Cypher statement is provided. It has the same content as the Cypher snippet at the beginning of this post. In fact, the content is included directly from this file.

Putting Neo4j-Migrations on the classpath by providing the Spring Boot starter will run all migrations from the default folder (resources/neo4j/migrations).

<dependency>
<groupId>eu.michael-simons.neo4j</groupId>
<artifactId>neo4j-migrations-spring-boot-starter</artifactId>
<version>${insert-current-neo4j-migrations.version}</version>
<scope>test</scope>
</dependency>

Testcontainers

Spring Boot 3.1 comes with new features for Testcontainers. One of these features is the automatic setting of properties without the need to define @DynamicPropertySource. The (to Spring Boot known) properties will get populated at test execution time after the container has started.

First, the dependency definition for Testcontainers Neo4j is needed in our pom.xml.

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>neo4j</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

To make use of Testcontainers Neo4j, a container definition interface will be created.

public interface Neo4jContainerConfiguration {
@Container
@ServiceConnection
Neo4jContainer<?> neo4jContainer = new Neo4jContainer<>(DockerImageName.parse("neo4j:5"))
.withRandomPassword()
.withReuse(true);
}

This can then be used with the @ImportTestContainers annotation in the (integration) test class.

@SpringBootTest
@ImportTestcontainers(Neo4jContainerConfiguration.class)
public class Neo4jGraphqlApplicationTests {
private final GraphQlTester graphQlTester;

@Autowired
public Neo4jGraphqlApplicationTests(ExecutionGraphQlService graphQlService) {
this.graphQlTester = ExecutionGraphQlServiceTester.builder(graphQlService).build();
}
@Test
void resultMatchesExpectation() {
String query = "{" +
" account(username:\"meistermeier\") {" +
" displayName" +
" }" +
"}";
this.graphQlTester.document(query)
.execute()
.path("account")
.matchesJson("[{\"displayName\":\"Gerrit Meier\"}]");
}
}

For completeness, this test class also includes the GraphQlTester and an example of how to test the application’s GraphQL API.

Testcontainers at Development Time

It is also now possible to run the whole application directly from the test folder and use a Testcontainers image.

@TestConfiguration(proxyBeanMethods = false)
public class TestNeo4jGraphqlApplication {
public static void main(String[] args) {
SpringApplication.from(Neo4jGraphqlApplication::main)
.with(TestNeo4jGraphqlApplication.class)
.run(args);
}
@Bean
@ServiceConnection
public Neo4jContainer<?> neo4jContainer() {
return new Neo4jContainer<>("neo4j:5").withRandomPassword();
}
}

The @ServiceConnection annotation also takes care that the application started from the test class knows the coordinates the container is running at (connection string, username, password…​).

To start the application outside the IDE, it is now also possible to invoke ./mvnw spring-boot:test-run. If there is only one class with a main method in the test folder, it will get started.

Topics Left Out / To Try Yourself

In parallel to the QueryByExampleExecutor, support for QuerydslPredicateExecutor exists in the Spring Data Neo4j module. To make use of it, the repository needs to extend the CrudRepository instead of the Neo4jRepository and also declare it as a QuerydslPredicateExecutor for the given type. Adding support for scrolling/pagination would require to also add the QuerydslDataFetcher.QuerydslBuilderCustomizer and implement its customize method.

The whole infrastructure presented in this blog post is also available for the reactive stack. Basically prefixing everything with Reactive…​ (like ReactiveQuerybyExampleExecutor) will turn this into a reactive application.

Last but not least, the scroll mechanism used here is based on an OffsetScrollPosition. There is also a KeysetScrollPosition that can be used. It makes use of the sort property/properties in combination with the defined id.

@Override
default QueryByExampleDataFetcher.Builder<Account, ?> customize(QueryByExampleDataFetcher.Builder<Account, ?> builder) {
return builder.sortBy(Sort.by("username"))
.defaultScrollSubrange(
new ScrollSubrange(ScrollPosition.keyset(), 1, true)
);
}

Summary

It’s nice to see how convenient methods in Spring Data modules not only provide broader accessibility for users’ use cases but also get used by other Spring projects to reduce the amount of code that needs to be written. This results in less maintenance for the existing code base and helps to focus on the business problem instead of infrastructure.

This post got a little bit longer because I explicitly want to touch at least the surface on what is happening when a query gets invoked without speaking just about the automagical result.

Please go ahead and explore more about what is possible and how the application behaves for different types of queries. It is nearly impossible to cover every topic and feature that is available within one blog post.

The example project can be found at GitHub.

Happy GraphQL coding and exploring.


Using Spring for GraphQL With Spring Data Neo4j was originally published in Neo4j Developer Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.