How Cypher Graph Types Kept Our AI-Generated Queries Honest During a Migration

We rebuilt GraphAcademy against the same Neo4j database. Graph types made sure AI-generated Cypher could not drift from the data model.

Over the past few months, we have been rebuilding GraphAcademy from the ground up. The codebase would be brand new; the database would stay exactly as it was.

A lot of our internal reporting relies on the graph, so changing the data model was too big a task for a rebuild aimed at improving our learner experience.

As an experiment, we decided the majority of the code should be AI-assisted. Coding agents are fast, but speed comes with trade-offs in quality. To make the migration a success, we needed to protect the database schema.

At the heart of that schema sits our learners' activity and achievements.

Protecting the learner data model

A User has an Enrolment, and the enrolment is for a Course. As a learner progresses, the enrolment collects COMPLETED_MODULE and COMPLETED_LESSON relationships. Progress, certificates, resuming where you left off: all start with a relationship from an enrolment.

The properties matter as much as the shape. Slugs and titles identify the content. Each enrolment carries a set of dates: createdAt, completedAt, lastSeenAt. If any of these changed in the rebuild, our reporting would break.

Where generated Cypher drifts

An AI-generated query that fails is easy to deal with. The dangerous one runs. A query that writes Enrollment instead of Enrolment, or completed_at instead of completedAt, may be syntactically correct, and a schema-optional database like Neo4j will happily accept it.

But Neo4j treats completedAt as a separate property from completed_at, so without the correct checks and balances, the data stored by the new website may drift.

Our conventions follow common Neo4j practice, as outlined in Graph Data Modeling Fundamentals: PascalCase for node labels, camelCase for properties. Every database technology has its own habits, and a model trained on all of them can easily mix conventions.

A style guide can only ask for that consistency. We needed the database to enforce it.

The old database became the template

Graph types, introduced in Cypher 25, gave us exactly that. We treated the existing database as the source of truth and declared its model as a graph type the new site had to conform to.

The User, identified by a unique sub:

cypher
ALTER CURRENT GRAPH TYPE SET {
  (:User => {
    sub :: STRING IS KEY,
    // ...
    createdAt :: ZONED DATETIME NOT NULL
  })
}

Then the Enrolment. Its dates are typed as ZONED DATETIME, so a string that merely looks like a date can't get through. createdAt is mandatory; completedAt and lastSeenAt only appear once a learner finishes or returns:

cypher
ALTER CURRENT GRAPH TYPE ADD {
  (:Enrolment => {
    id :: STRING IS KEY,
    // ...
    createdAt :: ZONED DATETIME NOT NULL,
    completedAt :: ZONED DATETIME,
    lastSeenAt :: ZONED DATETIME
  })
}

The content the enrolment points at:

cypher
ALTER CURRENT GRAPH TYPE ADD {
  (:Course => {
    slug :: STRING IS KEY,
    title :: STRING NOT NULL
  }),
  (:Module => {
    slug :: STRING NOT NULL,
    title :: STRING NOT NULL,
    order :: INTEGER NOT NULL
  }),
  (:Lesson => {
    slug :: STRING NOT NULL,
    title :: STRING NOT NULL,
    order :: INTEGER NOT NULL
  })
}

And the relationships that tie the model together:

cypher
ALTER CURRENT GRAPH TYPE ADD {
  (:User)-[:HAS_ENROLMENT => ]->(:Enrolment),
  (:Enrolment)-[:FOR_COURSE => ]->(:Course),
  (:Course)-[:HAS_MODULE => ]->(:Module),
  (:Module)-[:HAS_LESSON => ]->(:Lesson),
  (:Enrolment)-[:COMPLETED_MODULE => { createdAt :: ZONED DATETIME NOT NULL } ]->(:Module),
  (:Enrolment)-[:COMPLETED_LESSON => { createdAt :: ZONED DATETIME NOT NULL } ]->(:Lesson)
}

A graph type only constrains the elements it declares, and leaves the rest of the graph untouched. That meant we didn't have to model the whole database up front: we declared what mattered most and extended the graph type as features were added to the site.

Schema protection guaranteed

From then on, every integration test ran against a real Neo4j instance with the graph type applied. Any inconsistent write from AI-generated code was rejected on the spot, with an error naming exactly what didn't conform: a mislabelled node, a wrongly cased property, a date stored as a string. The core operational data stayed correct, guaranteed by the database itself.

This caught a handful of rogue queries in flight. For example, a hallucination that attempted to write an ENROLMENT_FOR relationship rather than FOR_COURSE. What would have become a headache in our Aura Dashboards was fixed the moment it surfaced.

Protect your own migration

If you're planning a migration of your own, treat your existing database as the specification. Declare the model you can't afford to lose as a graph type, run your tests against a real instance, and let the database reject the drift your reviews will miss.

Our enrolments survived an AI-written rewrite untouched; yours can survive whatever you throw at them.

Comments (0)

Loading comments...