Last year, I released the “Graph Model” library for .NET as an open source project. It was so much fun to work on that. Then my work on alwyse and my detour to build Spring Voyage took over all my time. It has been a very productive and creative year since then. CVOYA now has a portfolio of offerings and alwyse’s first limited beta is just around the corner. I can’t wait to start talking about it.
Few weeks ago, Paul Jeschke submitted Pull Request 66, which added support for Postgres+AGE as a provider for the Graph Model’s abstractions in .NET. I looked at Paul’s great work and I realized that my first attempt at the Graph Model framework had some serious layering issues. The Graph Model’s necessary abstractions weren’t layered correctly to make it easier for developers to build additional graph database providers. Paul had to duplicate a lot of the query expression translation machinery that was in the Neo4j provider for hir work on the AGE provider.
So, while still continuing to work on alwyse, I directed my agent team to evolve the graph library to make it easier for Paul and others to create providers. In the process, I also took the opportunity to clean up and evolve the library’s core functionality, especially given that I could now point better AI agents to the problem.
Even though I (and my agents) had to rewrite most of what Paul did, Paul deserves the contribution credit for the AGE provider. Thanks Paul!
“Graph Model” was renamed to “CVOYA Graph”. As my product manager AI agent indicated to me, a “model” is what users create using the library, not the library itself 🙂
However, the change is much more than a rename. Over a little more than two weeks, more than 130+ changes landed on the main branch, rebuilding the former GraphModel project into a provider-neutral graph platform for .NET 10. And yes, I reviewed all of them.
The package family and namespaces now use the Cvoya.Graph.* identity, the query engine has been separated from any one database, and the library now includes Neo4j, PostgreSQL with Apache AGE, and in-memory providers.
CVOYA Graph remains prerelease software but it’s closer to v1.0 now than it was before. If you used prior versions of the library, expect many API and storage schema breaking changes. For new applications, it establishes the foundation I want to build on. For existing GraphModel users, there is a migration guide.
What follows is a list of the major changes with examples. This new version contains a lot under the hood but the following are probably what developers will mostly care about.
CVOYA Graph exposes the same IGraph abstraction across three providers:
Cvoya.Graph.Neo4j — the complete Neo4j provider.Cvoya.Graph.Age — a new PostgreSQL and Apache AGE provider.Cvoya.Graph.InMemory — an in-process reference provider and application test double.First, you create an instance of a provider:
// Pick one
await using var store = new InMemoryGraphStore();
await using var store = new Neoj4GraphStore(...);
await using var store = new AgeGraphStore(...)
IGraph graph = store.Graph;Application query code now depends only on IGraph, not on the underlying database:
static Task<List<Person>> FindAdultsAsync(IGraph graph) =>
graph.Nodes<Person>()
.Where(person => person.Age >= 18)
.ToListAsync();The new query engine is now provider-neutral. LINQ expression trees first become a semantic graph query model. Cypher providers lower that model into a typed Cypher representation and render it through a database-specific dialect. The in-memory provider interprets the semantic model directly with LINQ-to-Objects and does not depend on Cypher at all.
This separation is what made Apache AGE and the in-memory provider practical without cloning the Neo4j query implementation. It also gives every provider one shared validation boundary: a query either translates with defined semantics or fails with an actionable error instead of silently returning different data.
The public query API now centers on a single IGraphQueryable<T> interface. Query roots are synchronous to construct because constructing a query performs no async operations. The execution of the constructed query expression tree remains asynchronous:
var people = await graph.Nodes<Person>()
.Where(person => person.Active)
.OrderBy(person => person.Name)
.ToListAsync();IGraphQueryable<T> also implements IAsyncEnumerable<T>, so a query can be consumed as an asynchronous stream:
await foreach (var person in graph.Nodes<Person>().Where(person => person.Active))
Console.WriteLine(person.Name);Traversal uses the relationship and endpoint types while inferring the starting node type from the query. A variable-length traversal can return either its endpoint nodes or full IGraphPath values:
var friends = await graph.Nodes<Person>()
.Where(person => person.Id == alice.Id)
.Traverse<Knows, Person>(minDepth: 1, maxDepth: 3)
.ToListAsync();
var paths = await graph.Nodes<Person>()
.Where(person => person.Id == alice.Id)
.TraversePaths<Knows, Person>(minDepth: 1, maxDepth: 3)
.ToListAsync();Each path exposes its Start, End, and ordered Segments. Filters, projections, paging, CountAsync, and AnyAsync can be composed over the path row before it is materialized.
The release adds a broader set of graph operations to the shared query model.
Relationship predicates filter during path expansion rather than after materialization:
/// Savas knows Jim since 1995. var recentFriends = await graph.Nodes<Person>()
.Where(p => p.FirstName == "Savas")
.Traverse<Knows, Person>(options => options
.WhereRelationship<Knows>(knows => knows.Since.Year >= 1995))
.Where(p => p.FirstName == "Jim")
.ToListAsync();When only relationship existence matters, WhereHasRelationship keeps the node query intact:
var connected = await graph.Nodes<Person>()
.WhereHasRelationship<Person, Knows>(GraphTraversalDirection.Both)
.ToListAsync();Shortest-path operators return one minimum path or every path tied at the minimum length:
var routes = await graph.Nodes<Person>()
.Where(person => person.Id == alice.Id)
.AllShortestPaths<Knows, Person>(person => person.Id == bob.Id)
.ToListAsync();Optional traversal preserves an unmatched source row and exposes a nullable target:
var assignments = await graph.Nodes<Person>()
.OptionalTraverse<WorksAt, Company>()
.Select(result => new {
PersonId = result.Source.Id,
Company = result.Target == null ? null : result.Target.Name,
})
.ToListAsync();Typed and dynamic node queries can also be filtered by stored labels:
var reviewers = await graph.Nodes<Person>()
.OfLabels(GraphLabelMatch.All, "Active", "SecurityReviewer")
.ToListAsync();And graph-typed Union and bag-preserving Concat keep the result inside the query pipeline:
var uniqueIds = await activeIds.Union(invitedIds).ToListAsync();
var auditIds = await activeIds.Concat(invitedIds).ToListAsync();CVOYA Graph can now project a node’s relationship count, or degree, without retrieving every relationship:
var stats = await graph.Nodes<Person>()
.Select(person => new {
person.Name,
FriendCount = person.CountRelationships<Knows>(GraphTraversalDirection.Both),
})
.ToListAsync();Scalar-key grouping supports count, long count, sum, average, minimum, and maximum aggregates:
var regions = await graph.Nodes<Person>()
.GroupBy(person => person.Region)
.Select(group => new {
Region = group.Key,
Count = group.LongCount()
})
.ToListAsync();Correlated collection projections can shape a nested collection for every graph row. The query remains server-side on database providers:
var social = await graph.Nodes<Person>()
.PathSegments<Person, Knows, Person>()
.GroupBy(segment => segment.StartNode)
.Select(group => new {
Name = group.Key.Name,
Friends = group.Select(segment => segment.EndNode.Name).ToList(),
})
.ToListAsync();These collections can also be filtered, ordered, aggregated, or grouped again within the supported shared grammar.
Full-text search now has a written cross-provider semantic contract. Declaring support guarantees case-insensitive, whole-token matching. Multiple search terms use AND semantics, and searchable properties are controlled through the graph model.
Search composes with ordinary LINQ:
var articles = await graph.Nodes<Article>()
.Where(article => article.Published)
.Search("graph databases")
.ToListAsync();A typed node search can also be the source of a traversal:
var network = await graph.SearchNodes<Person>("graph databases")
.Traverse<Knows, Person>()
.ToListAsync();Neo4j uses its native full-text indexes. I had to implement a custom solution for Apache AGE that uses a two-phase search plan backed by PostgreSQL GIN indexes. The in-memory provider implements the same minimum contract with an in-process matcher, making search behavior testable without external infrastructure.
Applications often need to create two endpoints and the relationship between them as one logical operation. The new CreateAsync overload does that directly:
await graph.CreateAsync(alice, new Knows(alice.Id, bob.Id), bob);Neo4j sends the complete node–relationship–node subgraph, including complex-property subtrees, in one Cypher round trip. Apache AGE uses one Npgsql batch boundary. The in-memory provider applies the operation atomically to its store.
When an endpoint may already exist, CreateMissingEndpoints reuses it without overwriting its existing properties:
await graph.CreateAsync(
alice,
new Knows(alice.Id, bob.Id),
bob,
new GraphOperationOptions { CreateMissingEndpoints = true });Complex properties are no longer a hidden, provider-specific serialization convention. A model can give the relationship to its value object a domain-meaningful type:
[Node("Person")]
public sealed record Person : Node {
public string Name { get; set; } = string.Empty
[ComplexProperty(RelationshipType = "LIVES_AT")]
public Address? HomeAddress { get; set; }
}HomeAddress is represented as graph structure such as (:Person)-[:LIVES_AT]->(:Address). Complex properties support nested navigation, collection projections, recursive materialization with a bounded depth, batched writes, and safe cascade cleanup.
I almost removed support for complex properties. I was really proud about it when I first designed and built it in the original Graph Model library released more than a year ago. But it was super complex and resulted in a graph structure in the database that effectively serialized the .NET in-memory object structure. Issue 199 captured my back-and-forth with my Architect AI agent. I was ready to drop the feature. Few text messages with Jim Webber and some more thinking helped me come up with [a .NET object with complex properties –> property graph mapping] that I should have implemented from the beginning. That’s “option D” in Issue 199
The optional Cvoya.Graph.Analyzers package catches invalid domain models while the application is being built. For example, a graph property must have public accessors:
[Property]
public string Name { get; private set; } = string.Empty; // CG002The analyzer suite covers constructors, accessors, unsupported types, duplicate labels, circular complex models, mismatched graph attributes, and the requirement that graph entities are reference types.
Valid domain models are picked up by Cvoya.Graph.Serialization.CodeGen, which emits their serialization and deserialization support at build time. Provider packages include the generator transitively, so applications do not need to hand-write serializers.
I’m excited about this. The new Cvoya.Graph.CompatibilityTests package is a reusable technology compatibility kit. A provider supplies a harness, declares its capabilities, and binds the shared test interfaces:
public sealed class BasicTests(MyHarness harness)
: MyProviderTest(harness), IBasicTests;Capabilities are explicit:
public CapabilitySet Capabilities => CapabilitySet.Of(
GraphCapability.Transactions,
GraphCapability.FullTextSearch,
GraphCapability.GroupByAggregation);The suite runs supported contracts and records unsupported capabilities with fixed, machine-readable reasons. Its compliance guard also catches provider test projects that accidentally discover or execute too little of the suite.
This is as important for CVOYA Graph itself as it is for external provider authors: Neo4j, Apache AGE, and the in-memory provider are all tested through the same contract surface. I’d love to see other graph database providers.
The providers share a large common surface, but they do not pretend that different graph engines support every operation identically.
Neo4j implements the complete current executable provider contract. The in-memory provider implements the same application-facing surface as an executable reference and test double. Apache AGE supports CRUD, transactions, traversal, optional traversal, complex properties, full-text search, scalar grouping, correlated projections, degree projection, label filtering, and atomic subgraph creation. I continue to work on making AGE fully compliant with the current capability set. Check out the CVOYA Graph open issues.
This release also establishes the machinery needed to publish CVOYA Graph as a coherent package family:
Cvoya.Graph.* NuGet packages with consistent CVOYA identity and metadata;The package family consists of:
Cvoya.Graph
Cvoya.Graph.Neo4j
Cvoya.Graph.Age
Cvoya.Graph.InMemory
Cvoya.Graph.Cypher
Cvoya.Graph.Analyzers
Cvoya.Graph.Serialization
Cvoya.Graph.Serialization.CodeGen
Cvoya.Graph.CompatibilityTestsInstall the provider for your database. The provider packages bring in the core abstractions and serialization generator:
> dotnet add package Cvoya.Graph.Neo4jFor PostgreSQL with Apache AGE:
> dotnet add package Cvoya.Graph.AgeFor application tests or local experimentation without a database:
> dotnet add package Cvoya.Graph.InMemoryI also recommend enabling the model analyzers:
> dotnet add package Cvoya.Graph.AnalyzersThis is a breaking prerelease. Existing users should review the complete migration guide. The most important changes are:
Cvoya.Graph.Model.* to Cvoya.Graph.*;Nodes<T>() and Relationships<T>() are synchronous to construct;IGraphQueryable<T>;Bidirectional direction value;PropertyAttribute.Label is now the physical storage key.The last two items may require stored-data migration, not just source changes. Do not upgrade an existing database without reviewing those sections of the migration guide.
CVOYA Graph v1.0.0-alpha.20260717 establishes a shared foundation: one domain model, one LINQ surface, explicit provider capabilities, and reusable compatibility tests across graph engines.
The immediate focus after this prerelease is to learn from real applications and provider implementations, tighten the remaining pre-1.0 contracts, and continue closing capability gaps without weakening cross-provider semantics. So share your feedback: file an issue, start a discussion thread , or just drop me a note.
Enjoy!
Today I'm pushing Spring Voyage out of the harbor. You can track its journey on…
In my last post, I wrote that "the typing of code was parallelized and delegated.…
In February, I wrote about the small team I'd stood up instead of hiring humans:…
Assembling a dream team without a single hire I've been making great progress on CVOYA's…
As 2025 is now behind us, I wanted to share a few reflections from my…
Few months ago, we bought a sculpture from a local art fair for our Palm…