CVOYA Graph – Evolution

, ,

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!

More than a rename

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

Major Changes

One graph API, three providers

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();

A shared query architecture

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.

One queryable surface, asynchronous execution

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);

Typed traversal and complete paths

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 StartEnd, and ordered Segments. Filters, projections, paging, CountAsync, and AnyAsync can be composed over the path row before it is materialized.

Graph-native operators

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();

Richer aggregation and projection

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 across providers

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.

Atomic subgraph creation

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 as first-class graph structure

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

Compile-time validation and serialization generation

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; // CG002

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

A compatibility suite for provider authors

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. 

Explicit provider capabilities

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.

A stronger release and engineering foundation

This release also establishes the machinery needed to publish CVOYA Graph as a coherent package family:

  • Nine Cvoya.Graph.* NuGet packages with consistent CVOYA identity and metadata;
  • Tag-triggered NuGet Trusted Publishing;
  • A clean-consumer build against the assembled packages;
  • Generated API documentation;
  • Strict provider compliance lanes for Neo4j, Apache AGE, and in-memory;
  • CodeQL and recommended .NET analyzer gates;
  • Build provenance attestations; and
  • An integrity-checked, fixed-name source archive attached to the GitHub release.
  • GitHub releases page

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

Getting started

Install the provider for your database. The provider packages bring in the core abstractions and serialization generator:

> dotnet add package Cvoya.Graph.Neo4j

For PostgreSQL with Apache AGE:

> dotnet add package Cvoya.Graph.Age

For application tests or local experimentation without a database:

> dotnet add package Cvoya.Graph.InMemory

I also recommend enabling the model analyzers:

> dotnet add package Cvoya.Graph.Analyzers

Upgrading from GraphModel

This is a breaking prerelease. Existing users should review the complete migration guide. The most important changes are:

  • package IDs and namespaces move from Cvoya.Graph.Model.* to Cvoya.Graph.*;
  • query roots such as Nodes<T>() and Relationships<T>() are synchronous to construct;
  • the node- and relationship-specific queryable interfaces are replaced by IGraphQueryable<T>;
  • traversal operators infer the starting node type and now use two explicit type arguments;
  • deprecated query compatibility members have been removed;
  • stored relationships no longer support a Bidirectional direction value;
  • complex properties use first-class semantic graph relationships; and
  • 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.

What comes next

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 issuestart a discussion thread , or just drop me a note

Enjoy!

Leave a Reply