Lesson 10

Real-World Modeling & App Integration

Difficulty Intermediate Time

Learning objectives

After this lesson you will be able to:

  1. Design a property-graph schema with clear identity keys, relationship semantics, and constraint strategy.
  2. Apply uniqueness constraints so application MERGE paths cannot fork entities.
  3. Connect with the official neo4j JavaScript driver and Python driver using parameterized queries only.
  4. Manage Driver, Session, and transaction lifecycle—including retry-friendly transaction functions.
  5. Implement a complete recommendation mini-project: model, seed, scoring query, integration sketch, tests, and production notes.
  6. Keep secrets out of source code using placeholders and environment-based configuration.
  7. Validate recommendation quality with simple offline checks before shipping.

Core concepts

Schema design for applications

Application graphs succeed when the model matches access patterns, not when it mirrors every spreadsheet column. Start from questions: “What movies will a user like next?”, “Which users share taste?”, “How do we ingest ratings safely?” Nodes represent stable entities (User, Movie, Person); relationships carry verbs and often properties (RATED {score, at}. Avoid untyped catch-all edges.

Identity keys must be unique and immutable from the application’s perspective: a UUID or external auth subject for users, a catalog id for movies. Display titles are not reliable primary keys (remakes, localizations). Place uniqueness constraints on those keys. Add RANGE indexes for frequent filters (email lookup, movie slug). Document labels and relationship types in a short schema README so every service uses the same vocabulary.

The Movie graph you used in lessons 01–09 is a teaching dataset. Production recommendation features typically extend it with User nodes and RATED relationships, or a separate ratings subgraph linked to the same movies. Keep write paths idempotent with MERGE on constrained keys.

Official drivers, sessions, and transactions

Neo4j supports official drivers for JavaScript/TypeScript, Python, Java, .NET, and Go. This lesson uses the JavaScript driver (neo4j-driver) and the Python driver (neo4j package). Create one Driver per application process. Drivers manage connection pooling and routing to cluster members. Do not open a new driver per HTTP request.

Acquire a Session for a unit of work, then run queries either as auto-commit statements or—preferred for writes and multi-statement logic—inside transaction functions (session.execute_read / execute_write in Python; session.executeRead / executeWrite in JavaScript). Transaction functions are retried automatically on transient errors when written as pure functions of the transaction object.

Always parameterize: pass a parameters map/dict, never concatenate user input into Cypher strings. Parameterization prevents Cypher injection and improves plan cache reuse. Close sessions when done; close the driver on process shutdown. Configuration values such as URI, username, and password must come from environment variables or a secrets manager—examples below use explicit placeholders only.

JAVASCRIPT
// Official neo4j-driver — placeholders only, no real secrets
import neo4j from 'neo4j-driver';

const uri = process.env.NEO4J_URI ?? 'neo4j://localhost:7687';
const user = process.env.NEO4J_USER ?? 'neo4j';
const password = process.env.NEO4J_PASSWORD ?? 'REPLACE_WITH_PASSWORD';

const driver = neo4j.driver(uri, neo4j.auth.basic(user, password));

async function moviesForActor(name) {
  const session = driver.session({ defaultAccessMode: neo4j.session.READ });
  try {
    return await session.executeRead(async (tx) => {
      const result = await tx.run(
        `MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie)
         RETURN m.title AS title ORDER BY title`,
        { name }
      );
      return result.records.map((r) => r.get('title'));
    });
  } finally {
    await session.close();
  }
}
PYTHON
# Official neo4j Python driver — placeholders only
import os
from neo4j import GraphDatabase, READ_ACCESS

URI = os.environ.get("NEO4J_URI", "neo4j://localhost:7687")
USER = os.environ.get("NEO4J_USER", "neo4j")
PASSWORD = os.environ.get("NEO4J_PASSWORD", "REPLACE_WITH_PASSWORD")

driver = GraphDatabase.driver(URI, auth=(USER, PASSWORD))

def movies_for_actor(name: str):
    def work(tx):
        result = tx.run(
            """
            MATCH (p:Person {name: $name})-[:ACTED_IN]->(m:Movie)
            RETURN m.title AS title ORDER BY title
            """,
            name=name,
        )
        return [record["title"] for record in result]

    with driver.session(default_access_mode=READ_ACCESS) as session:
        return session.execute_read(work)

Recommendation mini-project overview

Collaborative filtering on a graph: users who rated the same movies similarly can suggest unseen titles. Model:

  • (:User {userId}) with uniqueness on userId
  • (:Movie {movieId, title}) uniqueness on movieId
  • (:User)-[:RATED {score}]->(:Movie) with score in 1–5

Seed a few users and ratings, then score candidate movies by counting distinct co-raters or summing neighbor scores. Production systems add normalization, time decay, content features, and online metrics—but the graph pattern is the durable core. Integrate the scoring Cypher behind a read transaction in your API, cache top-N when needed, and test with fixed seed data.

User nodes rating Movie nodes with co-rating paths used to score unseen recommendations
Shared RATED edges create collaborative-filtering paths from a target user to candidate movies.

Worked examples

Dataset note: Expected results use the classic Neo4j Movie graph (37 movies, 133 people) plus the seed ratings below, and can differ if the data changes.

Schema constraints and seed ratings

Create constraints, merge users, attach stable movieId values to existing classic titles, and write RATED edges. Merge the relationship without baking score into the pattern—set the score afterward so re-runs update the property instead of creating duplicate relationships when scores differ.

CYPHER
CREATE CONSTRAINT user_id IF NOT EXISTS
FOR (u:User) REQUIRE u.userId IS UNIQUE;
CREATE CONSTRAINT movie_id IF NOT EXISTS
FOR (m:Movie) REQUIRE m.movieId IS UNIQUE;

MERGE (u1:User {userId: 'u-alice'}) SET u1.name = 'Alice'
MERGE (u2:User {userId: 'u-bob'}) SET u2.name = 'Bob'
MERGE (u3:User {userId: 'u-cara'}) SET u3.name = 'Cara'

WITH u1, u2, u3
MATCH (matrix:Movie {title: 'The Matrix'})
MATCH (cloud:Movie {title: 'Cloud Atlas'})
MATCH (topgun:Movie {title: 'Top Gun'})
SET matrix.movieId = coalesce(matrix.movieId, 'm-matrix'),
    cloud.movieId = coalesce(cloud.movieId, 'm-cloud'),
    topgun.movieId = coalesce(topgun.movieId, 'm-topgun')
MERGE (u1)-[r1:RATED]->(matrix) SET r1.score = 5
MERGE (u1)-[r2:RATED]->(cloud) SET r2.score = 4
MERGE (u2)-[r3:RATED]->(matrix) SET r3.score = 5
MERGE (u2)-[r4:RATED]->(topgun) SET r4.score = 5
MERGE (u3)-[r5:RATED]->(cloud) SET r5.score = 4
MERGE (u3)-[r6:RATED]->(topgun) SET r6.score = 3
RETURN u1.userId AS a, u2.userId AS b, u3.userId AS c

Expected result

abc
u-aliceu-bobu-cara

Scoring query for Alice

Find movies Alice has not rated, scored from users who share at least one highly rated movie with her (r.score >= 4 and r2.score >= 4). The candidate edge is also filtered with WHERE r3.score >= 4 so only strong neighbor ratings contribute—Cara’s score-3 rating of Top Gun is ignored, leaving Bob’s score-5 as the sole supporter (score 5, supporters 1). Without that strong-rating filter the sum would be 8 from two supporters.

CYPHER
MATCH (me:User {userId: 'u-alice'})-[r:RATED]->(m:Movie)
WHERE r.score >= 4
MATCH (other:User)-[r2:RATED]->(m)
WHERE other <> me AND r2.score >= 4
MATCH (other)-[r3:RATED]->(rec:Movie)
WHERE rec <> m
  AND NOT (me)-[:RATED]->(rec)
  AND r3.score >= 4
RETURN rec.title AS title,
       sum(r3.score) AS score,
       count(DISTINCT other) AS supporters
ORDER BY score DESC, supporters DESC, title
LIMIT 5

Expected result

title score supporters
Top Gun 5 1

JavaScript integration of the scoring query

Parameterized read transaction returning plain objects for an HTTP handler. No embedded secrets.

JAVASCRIPT
async function recommendFor(userId, limit = 5) {
  const session = driver.session({ defaultAccessMode: neo4j.session.READ });
  const cypher = `
    MATCH (me:User {userId: $userId})-[r:RATED]->(m:Movie)
    WHERE r.score >= 4
    MATCH (other:User)-[r2:RATED]->(m)
    WHERE other <> me AND r2.score >= 4
    MATCH (other)-[r3:RATED]->(rec:Movie)
    WHERE rec <> m AND NOT (me)-[:RATED]->(rec) AND r3.score >= 4
    RETURN rec.title AS title,
           sum(r3.score) AS score,
           count(DISTINCT other) AS supporters
    ORDER BY score DESC, supporters DESC, title
    LIMIT $limit`;
  try {
    return await session.executeRead(async (tx) => {
      const result = await tx.run(cypher, {
        userId,
        limit: neo4j.int(limit),
      });
      return result.records.map((rec) => ({
        title: rec.get('title'),
        score: rec.get('score'),
        supporters: rec.get('supporters').toNumber?.() ?? rec.get('supporters'),
      }));
    });
  } finally {
    await session.close();
  }
}

Expected semantic result for userId u-alice

title score supporters
Top Gun 5 1

Python write path for a new rating

Idempotent rating upsert inside execute_write. MATCH the constrained movie by movieId so a missing id returns no row instead of silently creating a title-less movie. Users may still be merged; relationship score is set after merge.

PYTHON
def upsert_rating(user_id: str, movie_id: str, score: int):
    cypher = """
    MATCH (m:Movie {movieId: $movie_id})
    MERGE (u:User {userId: $user_id})
    MERGE (u)-[r:RATED]->(m)
    SET r.score = $score
    RETURN u.userId AS userId, m.movieId AS movieId, r.score AS score
    """

    def work(tx):
        record = tx.run(
            cypher,
            user_id=user_id,
            movie_id=movie_id,
            score=score,
        ).single()
        if record is None:
            raise ValueError(f"unknown movieId: {movie_id}")
        return dict(record)

    with driver.session() as session:
        return session.execute_write(work)

# Example call with placeholders / test ids only
# upsert_rating("u-alice", "m-topgun", 5)

Expected result of upsert_rating("u-alice", "m-topgun", 5)

userId movieId score
u-alice m-topgun 5

Testing and production notes

How to ship the mini-project safely

Testing. Load a fixture graph (constraints + seed MERGEs) in a disposable database or Aura free instance. Assert that Alice receives Top Gun before she rates it, and that after she rates it the title disappears from recommendations. Integration tests should spin a session, run Cypher, and never print passwords. Prefer testcontainers or an ephemeral DBMS when CI allows.

Production. Store NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD in a secrets manager. Enable TLS-capable URIs (neo4j+s:// or bolt+s://) for hosted services. Set connection pool size to match concurrent request levels. Use read replicas routing for pure recommend paths when on clusters. Monitor latency and failure rates; PROFILE the scoring query on realistic cardinality and add indexes on User(userId) and Movie(movieId) (constraints already provide them).

Security. Never embed API keys, passwords, or cloud tokens in committed source, lesson notes, or client-side JavaScript bundles. Rate-limit recommendation endpoints, authenticate callers, and authorize that userId in the query matches the authenticated subject unless an admin role is present.

Evolution. When you outgrow simple co-rating, consider storing normalized preference weights, separating implicit plays from explicit stars, or hybridizing with content features (genres as nodes). The driver lifecycle and parameterization rules do not change as the Cypher grows more sophisticated.

Pitfalls

Exercises

  1. 1. Constraint pair

    Write Cypher that ensures uniqueness for User.userId and Movie.movieId using IF NOT EXISTS.

    STARTER
    CREATE CONSTRAINT ...
    CREATE CONSTRAINT ...
  2. 2. Recommend for Bob

    Adapt the scoring query to u-bob and predict which title should surface given the seed data.

    STARTER
    MATCH (me:User {userId: 'u-bob'})-[r:RATED]->(m:Movie)
    // continue co-rating pattern
    
  3. 3. Parameterized person lookup in JS

    Sketch a JavaScript executeRead that looks up a Person by $name and returns born year. Use env placeholders for auth.

    STARTER
    // const driver = neo4j.driver(process.env.NEO4J_URI, neo4j.auth.basic(...))
    // session.executeRead(tx => tx.run('MATCH (p:Person {name: $name}) RETURN p.born', { name }))
    
  4. 4. Python rating validation

    Extend upsert_rating so scores outside 1–5 raise a ValueError before opening a session.

    STARTER
    def upsert_rating(user_id: str, movie_id: str, score: int):
        if score < 1 or score > 5:
            raise ValueError("score must be 1..5")
        # then open session / execute_write
    

Lesson quiz

Five questions. Submit once to see your score and the full answer key.

Loading quiz…

Resources

Video

Watch on YouTube (opens in a new tab).