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:
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:
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:
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:
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...