Tutorial

Introduction

Neo4j-OGM Movies is an example application that allows you to manage movies, person related to the movies and ratings.

Although the functionality is reduced to show the needed steps to work with Neo4j-OGM, it contains all the layers you would expect to exist in a real world application.

We intentionally decided to not depend on any external framework (like Spring or Quarkus) in this example and exclude e.g. the web part, to avoid the extra bit of complexity those bring with them. As a benefit, we can more focus on the behaviour of Neo4j-OGM.

The complete source code for the application is available on Github.

Building the domain model

Before we get to any code, we want to whiteboard our graph model. We use https://arrows.app for this.

Our domain will contain Movies, each of it will have Persons in different relationships. We have the Actors, Directors and Reviewers.

Graph model

Defining the needed domain classes, results in the following code.

class Movie {
    String title;
    List<Actor> actors;
    List<Person> directors;
    List<Reviewer> reviewers;
}

class Person {
    String name;
}

class Actor {
	List<String> roles;

	Movie movie;
	Person person;
}

class Reviewer {
	String review;
	int rating;

	Movie movie;
	Person person;
}

Whenever a Person acted in a Movie, she or he played a certain or multiple characters (roles). To model this, we add relationship entities to the model that are known as relationship entity, in this case Actor.

We prepare a similar relationship representation for the Person who reviewed a Movie and call it Reviewer.

Configuring Neo4j-OGM

Neo4j-OGM depends on the Neo4j Java driver for interactions with the database. The driver itself uses the Bolt protocol to communicate with the Neo4j instance.

Dependencies

To start with Neo4j-OGM we need to add the core and Bolt dependency to our project. Even though Neo4j-OGM 4+ only provides support for connections via Bolt, the explicit dependency definition is needed.

Maven dependency for Neo4j-OGM
<dependencies>
    <dependency>
        <groupId>org.neo4j</groupId>
        <artifactId>neo4j-ogm-core</artifactId>
        <version>4.0.10</version>
    </dependency>
    <dependency>
        <groupId>org.neo4j</groupId>
        <artifactId>neo4j-ogm-bolt-driver</artifactId>
        <version>4.0.10</version>
    </dependency>
</dependencies>

If you are using Gradle or other systems, please do so accordingly.

Refer to Dependency Management for more information on dependencies.

Neo4j-OGM configuration

The configuration of Neo4j-OGM has two parts. First the definition of the SessionFactory configuration is needed. This is where you define the database connection. The most common parameters here are the Neo4j uri and the credentials, you want Neo4j-OGM to use for the connection.

The second step is the application specific definition of the package(s), Neo4j-OGM should scan for eligible domain classes.

Configuration configuration = new Configuration.Builder()
    .uri("neo4j://localhost:7687")
    .credentials("neo4j", "verysecret")
    .build();

SessionFactory sessionFactory = new SessionFactory(configuration, "org.neo4j.ogm.example");

Annotating the domain model

Much like Hibernate or JPA, Neo4j-OGM allows you to annotate your POJOs in order to map them to nodes, relationships and properties in the graph.

Node Entities

POJOs annotated with @NodeEntity will be represented as nodes in the graph.

The label assigned to this node can be specified via the label property on the annotation; if not specified, it will default to the simple class name of the entity. Each parent class in addition also contributes a label to the entity. This is useful when we want to retrieve collections of super types.

Let’s go ahead and annotate all our node entities in the code we wrote earlier.

@NodeEntity
public class Movie {
    String title;
    List<Actor> actors;
    List<Person> directors;
    List<Reviewer> reviewers;
}

@NodeEntity
public class Person {
    String name;
}

Relationships

Next up, the relationships between the nodes.

Every field in an entity that references another entity is backed by a relationship in the graph. The @Relationship annotation allows you to specify both the type of the relationship and the direction. By default, the direction is assumed to be OUTGOING and the type is the UPPER_SNAKE_CASE field name.

We’re going to be specific about the relationship type to avoid using the default and also make it easier to refactor classes later by not being dependent on the field name. Again, we are going to modify the code we saw in the last section:

@NodeEntity
public class Movie {
    String title;

    @Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
    List<Actor> actors;

    @Relationship(type = "DIRECTED", direction = Relationship.Direction.INCOMING)
    List<Person> directors;

    @Relationship(type = "REVIEWED", direction = Relationship.Direction.INCOMING)
    List<Reviewer> reviewers;
}

Relationship Entities

Sometimes something isn’t quite a Node entity.

In this demo the remaining classes to annotate are Actor and Reviewer. As discussed earlier, this is a relationship entity since it manages the underlying ACTED_IN and REVIEWED relations between a Movie and Person. They aren’t simple relationships because they hold properties like roles or summary and score.

A relationship entity must be annotated with @RelationshipEntity and also the type of relationship. Additionally, it needs a definition of where it comes from (Person) and where it goes to (Movie).

We are also going to indicate to Neo4j-OGM the start and end node of this relationship.

@RelationshipEntity(type = "ACTED_IN")
public class Actor {

    List<String> roles;

	@StartNode
    Person person;

    @EndNode
    Movie movie;
}

@RelationshipEntity("REVIEWED")
public class Reviewer {

    @Property("summary")
    String review;

    int rating;

    @EndNode
    Movie movie;

    @StartNode
    Person person;
}

Identifiers

Every node and relationship persisted to the graph must have an id. Neo4j-OGM uses this to identify and re-connect the entity to the graph in memory. Identifier may be either a primary id or a native graph id.

  • primary id - any property annotated with @Id, set by the user and optionally with @GeneratedValue annotation

  • native id - this id corresponds to the id generated by the Neo4j database when a node or relationship is first saved, must be of type Long

Do not rely on native id for long-running applications. Neo4j will reuse deleted node id’s. It is recommended users come up with their own unique identifier for their domain objects (or use a UUID).

See Node Entities for more information.

Our entities will now also define the @Id fields. For the Movie we have chosen the title and for the Person the name property. In a real-world scenario names and titles cannot be seen as unique, but for this example it is enough.

@NodeEntity
public class Movie {
    @Id
    String title;

    @Relationship(type = "ACTED_IN", direction = Relationship.Direction.INCOMING)
    List<Actor> actors;

    @Relationship(type = "DIRECTED", direction = Relationship.Direction.INCOMING)
    List<Person> directors;

    @Relationship(type = "REVIEWED", direction = Relationship.Direction.INCOMING)
    List<Reviewer> reviewers;
}

@NodeEntity
public class Person {
    @Id
    String name;
}

Also, we add the generated internal id references to the relationship entities.

@RelationshipEntity(type = "ACTED_IN")
public class Actor {

    @Id @GeneratedValue
    Long id;

    List<String> roles;

    @StartNode
    Person person;

    @EndNode
    Movie movie;
}

@RelationshipEntity("REVIEWED")
public class Reviewer {

    @Id @GeneratedValue
    Long id;

    @Property("summary")
    String review;

    int rating;

    @EndNode
    Movie movie;

    @StartNode
    Person person;
}

No Arg Constructor

We are almost there!

Neo4j-OGM also requires a public no-args constructor to be able to construct objects from all our annotated entities. Since we haven’t defined any custom constructors, we are good to go.

Interacting with the model

So our domain entities are annotated, now we’re ready persist them to the graph!

Sessions

The smart object mapping capability is provided by the Session object. A Session is obtained from a SessionFactory.

We’re going to set up the SessionFactory just once and have it produce as many sessions as required.

The Session keeps track of changes made to entities and relationships and persists ones that have been modified on save. Once an entity is tracked by the session, reloading this entity within the scope of the same session will result in the session cache returning the previously loaded entity. However, the subgraph in the session will expand if the entity or its related entities retrieve additional relationships from the graph.

For the purpose of this example application, we’ll use short living sessions - a new session per operation - to avoid stale data issues.

Our example application will use the following operations:

public class MovieService {

    Movie findMovieByTitle(String title) {
        // implementation
    }

    List<Movie> allMovies() {
        // implementation
    }

    Movie updateTagline(String title, String newTagline) {
        // implementation
    }
}

These CRUD interactions with the graph are all handled by the Session. Whenever we want to run a unit of work, we open the session and do all operations with the same Session instance. Let’s have a look at the implementations (and the constructor with the SessionFactory instantiation) of the service:

public class MovieService {

    final SessionFactory sessionFactory;

    public MovieService() {
        Configuration config = new Configuration.Builder()
            .uri("neo4j://localhost:7687")
            .credentials("neo4j", "verysecret")
            .build();
        this.sessionFactory = new SessionFactory(config, "org.neo4j.ogm.example");
    }

    Movie findMovieByTitle(String title) {
        Session session = sessionFactory.openSession();
        return Optional.ofNullable(
                session.queryForObject(Movie.class, "MATCH (m:Movie {title:$title}) return m", Map.of("title", title)))
            .orElseThrow(() -> new MovieNotFoundException(title));
    }

    public List<Movie> allMovies() {
        Session session = sessionFactory.openSession();
        return new ArrayList<>(session.loadAll(Movie.class));
    }

    Movie updateTagline(String title, String newTagline) {
        Session session = sessionFactory.openSession();
        Movie movie = session.queryForObject(Movie.class, "MATCH (m:Movie{title:$title}) return m", Map.of("title", title));
        Movie updatedMovie = movie.withTagline(newTagline);
        session.save(updatedMovie);
        return updatedMovie;
    }
}

As you can see in the updateTagline method, the Session gets opened right at the start to have the loading and persisting operation within the same instance.

When the service fetches all Movies, the default load depth of 1 gets applied to the operation. A higher value does not have any impact on our example because we are just loading Movies and their direct neighbours. The chapter on load depth gives you more insights of the default behaviour of relationship traversal.

Custom queries and mapping

As you have already seen, we have used custom queries with Session#query to fetch a Movie by title. Sometimes you end up with data returning from your query that does not fit into one of the entities, you have defined previously.

Let’s say, we want to collect the average rating of a Movie joined with all reviewers. Sure we could do this also in the application itself but preparing data on the database side has not only the benefit of reducing the data over the wire, but also it’s something the database can do much faster in most of the cases.

public record MovieRating(String title, float rating, List<String> reviewers) {
}

Adding another method to our service including the custom query results in:

List<MovieRating> getRatings() {
    Session session = sessionFactory.openSession();
    List<MovieRating> ratings = session.queryDto(
        "MATCH (m:Movie)<-[r:REVIEWED]-(p:Person) RETURN m.title as title, avg(r.rating) as rating, collect(p.name) as reviewers",
        Map.of(),
        MovieRating.class);
    return ratings;
}

Conclusion

With not much effort, we’ve built all the services that tie together this application. All that is required is adding controllers and building the UI. The fully functioning application is available at Github.

We encourage you to read the reference guide that follows and apply the concepts learned by forking the application and adding to it.