Chapter 2

Graph Data Modeling

Effective graph data modeling is the foundation of a successful graph database implementation. Unlike relational modeling, which focuses on normalizing data into tables, graph modeling emphasizes the natural relationships in your domain. This chapter explores principles, patterns, and best practices for designing graph schemas that are both performant and maintainable.

Understanding Graph Data Models

Graph data modeling starts with identifying entities (nodes) and their relationships (edges). The key is to think in terms of connections rather than tables. What matters most are the questions you need to answer and the patterns you need to traverse.

The Whiteboard Model

One of the most powerful aspects of graph databases is that your initial whiteboard sketch can directly translate into your database schema. When stakeholders draw diagrams with circles and arrows to explain their domain, that diagram IS your data model.

Modeling Principle: In graph databases, you model what you whiteboard. There's no impedance mismatch between your conceptual model and your implementation.

Nodes: Representing Entities

Nodes represent the entities in your domain. Each node can have:

Edges: Modeling Relationships

Edges connect nodes and represent relationships. Each edge has:

Modeling Principles

Several key principles guide effective graph modeling:

1. Model Based on Use Cases

Start with the questions you need to answer. Your data model should optimize for your most important queries. List your top 10 queries and ensure your model supports them efficiently.

Example Questions:

2. Nodes for Entities, Edges for Relationships

The fundamental rule: if something is a discrete entity that you might want to query independently, make it a node. If it only exists to connect two entities, make it a relationship.

Decision Rule:

3. Fine-Grained vs. Aggregated Models

Choose the appropriate granularity for your use case:

Aspect Fine-Grained Aggregated
Level of Detail Every interaction is a node Summarized interactions
Example Every email is a node Email count as edge property
Best For Detailed analysis, audit trails Performance, analytics
Trade-off More nodes, detailed queries Fewer nodes, faster traversals

4. Relationship Direction Matters

Always give relationships a meaningful direction, even if you plan to traverse in both directions. This adds semantic clarity and often improves query performance.

// Good: Clear semantic meaning
(person:Person)-[:PURCHASED]->(product:Product)
(employee:Person)-[:WORKS_FOR]->(company:Company)

// Avoid: Bidirectional relationship names
(person)-[:CONNECTED_TO]-(person) // Which direction?

5. Use Specific Relationship Types

Specific relationship types make queries more efficient and expressive:

// Good: Specific types
(user)-[:FRIEND_REQUEST_SENT]->(other)
(user)-[:FRIEND_REQUEST_RECEIVED]-(other)
(user)-[:FRIENDS_WITH]-(other)

// Less Good: Generic types with properties
(user)-[:RELATIONSHIP {type: 'friend_request', direction: 'sent'}]->(other)

Common Modeling Patterns

Several patterns appear frequently in graph modeling:

Pattern 1: Intermediate Nodes

When a relationship has many properties or connects to other entities, consider making it a node:

// Before: Relationship with many properties
(user)-[:EMPLOYED {title: 'Engineer', salary: 100000, startDate: '2020-01-01',
                   department: 'Engineering', manager: 'Bob'}]->(company)

// Better: Employment as a node
(user)-[:HAS_EMPLOYMENT]->(employment:Employment)
(employment)-[:AT_COMPANY]->(company)
(employment)-[:IN_DEPARTMENT]->(dept:Department)
(employment)-[:MANAGED_BY]->(manager:Person)

This pattern is especially useful for:

Pattern 2: Linked Lists for Ordering

To maintain ordered sequences, use linked list patterns:

// Maintain order of events
(event1:Event)-[:NEXT]->(event2:Event)-[:NEXT]->(event3:Event)

// Or bidirectional for easy traversal
(event1)-[:NEXT]->(event2)<-[:PREV]-(event1)

Pattern 3: Time Trees

For time-based queries, organize temporal data in tree structures:

// Year -> Month -> Day -> Event
(root:Root)
  -[:YEAR]->(y2024:Year {year: 2024})
    -[:MONTH]->(m01:Month {month: 1})
      -[:DAY]->(d15:Day {day: 15})
        -[:EVENT]->(event:Event)

This enables efficient queries like "all events in January 2024" without scanning all events.

Pattern 4: Category Hierarchies

Model taxonomies and hierarchies naturally:

(electronics:Category {name: 'Electronics'})
  -[:SUBCATEGORY]->(computers:Category {name: 'Computers'})
    -[:SUBCATEGORY]->(laptops:Category {name: 'Laptops'})

(product)-[:IN_CATEGORY]->(laptops)

Pattern 5: Hyperedges with Intermediate Nodes

When a relationship involves more than two entities:

// A transaction involves a buyer, seller, and product
(buyer:User)-[:BUYER]->(transaction:Transaction)
(seller:User)-[:SELLER]->(transaction)
(product:Product)-[:ITEM]->(transaction)
(transaction {date: '2024-01-15', amount: 99.99})

Schema Design Considerations

Labeling Strategy

Choose a consistent labeling strategy for your nodes:

Best Practice: Use singular nouns for labels (Person, not People) and UPPER_SNAKE_CASE for relationship types (WORKS_FOR, not worksFor).

Property Design

Guidelines for properties:

Indexing Strategy

Plan your indexes based on access patterns:

// Create index on frequently queried properties
CREATE INDEX person_email FOR (p:Person) ON (p.email);
CREATE INDEX product_sku FOR (p:Product) ON (p.sku);

// Composite indexes for multiple properties
CREATE INDEX person_name_city FOR (p:Person) ON (p.lastName, p.city);

// Full-text indexes for text search
CREATE FULLTEXT INDEX person_search FOR (p:Person) ON EACH [p.name, p.bio];

Anti-Patterns to Avoid

Common mistakes in graph modeling:

1. Property Overload

Problem: Storing too much data as properties instead of relationships

// Bad: Address as properties
(person {street: '123 Main St', city: 'NYC', state: 'NY', zip: '10001'})

// Better: Address as related node
(person)-[:LIVES_AT]->(address:Address {street: '123 Main St', ...})

2. Dense Nodes

Problem: Nodes with thousands or millions of relationships can cause performance issues

Solution: Use intermediate nodes or categorization:

// Bad: Every user connected to one category node
(user)-[:INTERESTED_IN]->(sports:Category)
// This creates a "supernode" with millions of connections

// Better: Use subcategories or intermediate groupings
(user)-[:INTERESTED_IN]->(football:Subcategory)-[:PARENT]->(sports:Category)

3. Using Relationships as Attributes

Problem: Creating relationships to represent simple attributes

// Bad: Color as a node for every product
(product)-[:HAS_COLOR]->(red:Color)

// Better: Color as a property (unless colors have relationships)
(product {color: 'red'})

4. Ignoring Query Patterns

Problem: Modeling "correctly" without considering actual queries

Solution: Design for your queries first, theoretical purity second. If you always traverse A→B→C together, optimize that path.

Modeling for Different Domains

Social Networks

// Core model
(person:Person {name, email, joinedDate})
(person)-[:KNOWS {since}]->(friend:Person)
(person)-[:POSTED]->(post:Post {content, timestamp})
(person)-[:LIKES]->(post)
(person)-[:COMMENTED {text, timestamp}]->(post)
(post)-[:TAGGED]->(topic:Topic)

E-Commerce

// Customer and products
(customer:User {name, email})
(product:Product {name, price, sku})
(category:Category {name})

// Purchase history
(customer)-[:PURCHASED {date, quantity}]->(product)
(product)-[:IN_CATEGORY]->(category)
(category)-[:SUBCATEGORY]->(parent:Category)

// Recommendations
(customer)-[:VIEWED]->(product)
(customer)-[:ADDED_TO_CART]->(product)
(customer)-[:REVIEWED {rating, text, date}]->(product)

Fraud Detection

// Accounts and transactions
(account:Account {accountNumber, balance})
(transaction:Transaction {amount, timestamp, type})
(device:Device {deviceId, ipAddress})

// Relationships
(account)-[:INITIATED]->(transaction)
(transaction)-[:TO_ACCOUNT]->(recipient:Account)
(device)-[:USED_FOR]->(transaction)
(person:Person)-[:OWNS]->(account)

// Risk indicators
(account)-[:SHARES_PHONE]->(account2)
(account)-[:SHARES_ADDRESS]->(account2)

Migrating from Relational Models

When migrating from a relational database:

Tables → Nodes

Most tables become node labels. Each row becomes a node.

-- SQL Table
CREATE TABLE persons (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100)
);

// Graph Equivalent
CREATE (p:Person {id: 1, name: 'Alice', email: 'alice@example.com'})

Foreign Keys → Relationships

Foreign key relationships become edges:

-- SQL
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    company_id INT REFERENCES companies(id)
);

// Graph
(employee:Employee)-[:WORKS_FOR]->(company:Company)

Join Tables → Relationships or Intermediate Nodes

Many-to-many join tables can become either relationships (if simple) or intermediate nodes (if they have attributes):

-- SQL Join Table
CREATE TABLE enrollments (
    student_id INT REFERENCES students(id),
    course_id INT REFERENCES courses(id),
    grade VARCHAR(2),
    semester VARCHAR(20)
);

// Graph Option 1: Relationship with properties
(student)-[:ENROLLED {grade: 'A', semester: 'Fall 2024'}]->(course)

// Graph Option 2: Intermediate node (better for complex cases)
(student)-[:HAS_ENROLLMENT]->(enrollment:Enrollment)
(enrollment)-[:IN_COURSE]->(course)
(enrollment {grade: 'A', semester: 'Fall 2024'})

Testing Your Model

Before finalizing your schema:

  1. Write Sample Queries: Express your top 10 queries in Cypher to verify the model supports them efficiently
  2. Check Traversal Depth: Ensure common queries don't require excessive hops (>4-5 is often too many)
  3. Load Test Data: Import a representative dataset and measure query performance
  4. Review with Stakeholders: Draw your graph model and verify it matches domain understanding
  5. Plan for Evolution: Ensure you can add new node types and relationships without breaking existing queries

Model Evolution and Versioning

Graph schemas are flexible, but plan for evolution:

Adding New Elements

Adding new node labels, relationship types, or properties is straightforward and non-breaking:

// Add a new relationship type
MATCH (p:Person), (c:Company)
WHERE p.companyName = c.name
CREATE (p)-[:WORKS_FOR]->(c);

// Add properties to existing nodes
MATCH (p:Person)
SET p.status = 'active';

Refactoring Existing Structure

When you need to change existing structure:

// Split a property into separate nodes
MATCH (p:Product)
WHERE p.category IS NOT NULL
MERGE (c:Category {name: p.category})
CREATE (p)-[:IN_CATEGORY]->(c)
REMOVE p.category;

Versioning Strategies

Chapter Summary

Graph data modeling is an iterative process that starts with understanding your domain and the questions you need to answer. By modeling entities as nodes and relationships as edges, and following established patterns, you can create schemas that are both intuitive and performant. Remember that graph databases are schema-flexible, allowing your model to evolve as your understanding deepens.

In the next chapter, we'll explore Neo4j in depth, learning how to implement these modeling principles in the world's most popular graph database.

Chapter 2 — Notes & References

  1. WIA Standards Public Repository (graph-database folder), MIT License, GitHub: WIA-Official/wia-standards-public/tree/main/graph-database — open standard initiative providing source code for simulator, spec, API, and ebook assets cited throughout this volume; serves as the canonical verification record for all primary-source citations made by the WIA standard committee in this chapter. Canonical ENUM tokens used in this volume include NEO4J, NEPTUNE, DGRAPH, ARANGODB, JANUSGRAPH, TIGERGRAPH, MEMGRAPH, NEBULA_GRAPH, CYPHER, GREMLIN, SPARQL, GQL, PAGERANK, LOUVAIN, NODE2VEC, GRAPHSAGE, ISO_GQL_2024, W3C_RDF, W3C_SPARQL, LDBC_SNB, WIKIDATA, DBPEDIA, YAGO, CONCEPTNET.