A GraphQL + JanusGraph Web App

While relational databases are a tried and true solution to persisting structured data, they're not designed to efficiently connect the data between two tables. At the same time, the real-world data we attempt to model in today's software applications is increasingly about the relationships between objects. It's more intuitive to think about your data in terms of nodes and relationships rather than tables and columns, so shouldn't our databases be optimized for data whose meaning is as much about the relationship between objects as the properties of those objects? This is where graph databases shine. Here's a great article on how graph databases attempt to overcome the shortcomings of their relational counterpart.

In this post, I'll explain how I set up a sample web-app using JanusGraph. In the graph db world there are three main choices. Neo4j, Orient DB, and JanusGraph (with Amazon Neptune on the way). I wont go into the differences and trade-offs of each system here, that would be a post on its own, but I'll mention that I chose JanusGraph because it offers flexibility in terms of which CAP-theorem guarantees you would like your system to make. Also, it's newer, meaning the developer ecosystem is less mature and there's more of a need for JanusGraph examples and tutorials floating around the web. More information on the advantages of JanusGraph.

View the code on GitHub

Toolkit

A rundown of how the app was built.

Domain layer

The project is split into 2 main packages, framework and starwars. The framework package is a thin wrapper around the Ferma OGM (object-graph-mapper) which adds better type-safety and makes it easier to do common traversals.The starwars package demonstrates how any data model could plug into the framework package and Ferma OGM with minimal effort. The domain layer follows the common Model-View-Controller pattern. Here's a quick sample of what each component looks like.

Model

abstract class Character: Node() {

    @Property("name")
    abstract fun getName(): String

    @Property("name")
    abstract fun setName(name: String)

    val toAppearsIn get() = to(appearsIn)

    val toFriends get() = to(friends)

    val toSecondDegreeFriends get() = to(secondDegreeFriends)

    @Incidence(label = "friends")
    abstract fun addFriend(friend: Character)

    @Adjacency(label = "friends")
    abstract fun setFriends(friends: Set<Character>)

    @Incidence(label = "appearsIn")
    abstract fun addAppearsIn(appearsIn: Episode)

    @Adjacency(label = "appearsIn")
    abstract fun setAppearsIn(appearsIn: Set<Episode>)

    companion object {
        val friends = Relationship.AsymmetricManyToMany<Character, Character>(name = "friends")
        val secondDegreeFriends = friends.to(friends)
        val appearsIn = Relationship.AsymmetricManyToMany<Character, Episode>(name = "appearsIn")
    }
}

Here we define a Character which has a getter and setter for its name property. The annotations are used to inform the ogm of the shape of the graph. @Incidence and @Adjacency are both included for illustrative purpses. An incidence represents a connection between a vertex (aka node) and an edge, while an adjacency represents a vertex connection to another vertex (through an edge).

View (GraphQL schema)

# A character in the Star Wars Trilogy
interface Character {

    # The id of the character
    id: ID!

    # The name of the character
    name: String!

    # The friends of the character, or an empty list if they have none
    friends: [Character!]!

    # Which movies they appear in
    appearsIn: [Episode!]!

    # The friends of the droid, or an empty list if they have none
    secondDegreeFriends(limit: Int): [Character!]!    
}

type Mutation {

    # Create a new droid
    createDroid(name: String!, primaryFunction: String!, friendIds: [ID!]!, appearsInIds: [ID!]!): Droid!
}

Here we define the Character interface and a mutation to create a new Droid that will be publicly exposed to the world via the GraphQL. The graphql-tools library allows us to write this in the GraphQL Schema Language.

Controller

interface CharacterTypeResolver: NodeTypeResolver {

    val loader: TraversalLoader

    fun getName(character: Character) = character.getName()
    fun getAppearsIn(character: Character) = loader.fetch(character.toAppearsIn)
    fun getFriends(character: Character) = loader.fetch(character.toFriends)

    fun getSecondDegreeFriends(character: Character, limit: Int?) = loader.fetchMany<Character> {
        val secondDegree = character.toSecondDegreeFriends.asGremlin(it)
                .dedup()
                .filter { it.get().id() != character.getId() }
        if (limit == null) secondDegree else secondDegree.limit(limit.toLong())
    }
}

class DroidMutationResolver(
        val graph: FramedGraph
) : GraphQLMutationResolver {

    fun createDroid(
            name: String,
            primaryFunction: String,
            friendIds: Set<Long>,
            appearsInIds: Set<Long>,
            environment: DataFetchingEnvironment): Droid {
        val friends = graph.fetchMany<Character> { it.vertexIds(friendIds) }
        val droid = graph.insert<Droid>()
        droid.setName(name)
        droid.setPrimaryFunction(primaryFunction)
        droid.setAppearsIn(graph.fetchMany { it.vertexIds(appearsInIds) })
        droid.setFriends(friends)
        return droid
    }
}

The controllers (aka resolvers) connect our GraphQL schema with our internal datamodel and apply any necessary application logic such as fetching/inserting nodes, traversing edges and manipulating data as requested through our service's GraphQL interface.

Framework layer

The framework layer adds some features to make working with your graph more convenient. You'll notice there's only a few references to JanusGraph throughout the entire codebase. You might assume the object-graph-mapper we're using is abstracting away direct communication with JanusGraph, however that's not quite correct. The Ferma OGM is actually a layer of abstraction on top of Gremlin. Gremlin is a generic traversal language that many graph databases support, including JanusGraph. Think of it as SQL, but for graph databases. Although there is a java implementation of Gremlin available, it lacks support for generics, which is why wrapping it with an OGM such as Ferma can be useful. In addition, the framework package offers a much richer way to define relationships than could be achieved with just Ferma or Gremlin-java alone.

Concepts

Here's an overview of some of the concepts I've introduced, which provide guard rails and add greater semantic meaning to the relationships in your graph:

  • Relationship
    A Relationship describes a path through the graph from one Node type to a second Node type.

  • Hop
    A Hop is a special type of Relationship that doesn't go through any other Node types. In other words, a Hop is a Relationship with a distance equal to 1.

  • Symmetric vs Asymmetric
    Hops may optionally have a direction. If a Hop has a Direction, it is said to be Asymmetric. A 'mother' relationship would be an example of an Asymmetric Relationship. On the other hand a 'sibling' Relationship would be an example of a Symmetric Relationship.

  • ToOptional vs ToSingle vs ToMany
    The cardinality of a Relationship can be expressed in one of 3 ways. ToOptional indicates the destination Node may or may not exist. ToSingle indicates the destination Node is guaranteed to exist. ToMany indicates there may be any number of destination nodes. Because we can call Relationship.inverse it is also important to define the 'from' cardinality of a relationship in terms of FromOptional FromSingle or FromMany.

  • Traversal
    A Traversal is a simple structure that encapsulates both a Relationship and the Node(s) to start from when preparing to traverse a Relationship through the graph. Traversals are eventually translated to gremlin syntax so they can be run on the graph.

Check out the full source code on GitHub

Stay tuned for a subsequent post explaining how to deploy this app to the cloud running on a cluster.

Show Comments