Note: To use attribute-based configuration, you only need to install the Neo4j provider package:
dotnet add package Cvoya.Graph.Neo4j
The analyzers package is optional but recommended for extra compile-time validation:
dotnet add package Cvoya.Graph.Analyzers
CVOYA Graph uses attributes to provide declarative configuration for how your domain classes map to graph elements. This approach offers clean, type-safe configuration with support for indexing, custom labeling, and property control.
The [Node] attribute specifies how a class maps to a graph node with custom labeling support.
[Node("Person")]
public record Person : Node
{
public string Name { get; set; } = string.Empty;
}Default Behavior: Without the attribute, the class name is used as the node label:
// This will create nodes with label "Employee"
public record Employee : Node
{
}One label per type: a node type maps to exactly one label, and a relationship type to exactly one type name. This label is the correlation key between the stored node and the .NET type used to materialize it (see Type resolution below).
Uniqueness: the label must be unique across every node type loaded in the process, compared case-insensitively (Person and person are treated as the same label). Two loaded node types that resolve to the same label — whether both declare it explicitly, or one falls back to a class name that matches another's label — are rejected at registration with a GraphException. The same rule applies independently to relationship type names. This mirrors the compile-time analyzers (CG008/CG009), which flag the collision before you run.
Multiple labels on a single typed node are not supported. If you need arbitrary, runtime-defined label sets (for example cross-cutting tags), use
DynamicNode, whoseLabelscollection you manage yourself.
The [Property] attribute provides fine-grained control over property mapping, including custom names, indexing, and serialization behavior:
[Node("Person")]
public record Person : Node
{
[Property(Label = "full_name")]
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
[Property(Label = "birth_date")]
public DateTime DateOfBirth { get; set; }
[Property(Ignore = true)]
public string TemporaryCalculation { get; set; } = string.Empty;
// This property uses its own name in the graph
public int Age { get; set; }
}| Option | Description | Example |
|---|---|---|
Label |
Custom property name in graph storage | [Property(Label = "full_name")] |
Ignore |
Exclude from graph persistence | [Property(Ignore = true)] |
IsKey |
Include the property in the entity's optional domain-key tuple | [Property(IsKey = true)] |
IsUnique |
Require this individual property's value to be unique within the mapped label/type | [Property(IsUnique = true)] |
IsRequired |
Require a stored non-null value | [Property(IsRequired = true)] |
IsIndexed |
Request a provider index for the property | [Property(IsIndexed = true)] |
Note: Providers decide how to apply requested indexes and constraints for their storage engine.
A node or relationship may be keyless. A property named Id is not inferred as a key; only an
explicit IsKey = true declaration participates in the domain key.
[Node("Person")]
public record Person : Node
{
// Keyless: Name is ordinary data and no domain key is declared.
public string Name { get; init; } = string.Empty;
}
[Node("Customer")]
public record Customer : Node
{
[Property(IsKey = true)]
public string Tenant { get; init; } = string.Empty;
[Property(IsKey = true)]
public string CustomerNumber { get; init; } = string.Empty;
}All IsKey properties on one entity form one ordered tuple. The Customer example therefore has
one composite key (CustomerNumber, Tenant) (the runtime schema orders mapped property names
ordinally), not two individually unique properties. Set IsUnique = true on a component only when
that component must also be independently unique.
Keys are scoped to the mapped node label or relationship type in one configured graph store. They
must be non-nullable, graph-storable scalar values; collections, complex values, ignored properties,
and unsupported property types are rejected at runtime even when the analyzer package is not
installed. With the analyzer installed, invalid key declarations and ignored-property schema
conflicts are also reported at build time as CG018 (a key on a property type that is unsupported
regardless of key configuration keeps its existing property-type diagnostic). IsKey implies
IsRequired and IsIndexed, but does not set the explicit IsUnique schema flag.
A domain key is not provider-native graph element identity, is not an implicit endpoint or mutation target, and is not automatically immutable. Key values may change through an operation that can preserve the applicable constraints. An ignored property cannot also request key, unique, indexed, or required behavior.
In v1.0, a simple collection cannot declare either IsKey = true or IsUnique = true,
regardless of whether its element type is nullable. Collection equality and uniqueness do not have
a portable cross-provider graph contract, so schema registration rejects these declarations before
any provider creates a constraint or performs mutation uniqueness preflight. Ordinary
non-constrained collections remain supported.
Supported property types:
- Primitive types:
int,long,float,double,decimal,bool stringDateTime,DateTimeOffset,DateOnly,TimeOnlyGuidUri- Enums
- Collections of simple or complex element types, declared as a one-dimensional array,
List<T>, orHashSet<T>, or as an interface they satisfy:IEnumerable<T>,ICollection<T>,IList<T>,IReadOnlyCollection<T>,IReadOnlyList<T>,ISet<T>, orIReadOnlySet<T>. A set-typed declaration round-trips as aHashSet<T>; every other shape as aList<T>(or an array). Other concrete collection types (for exampleQueue<T>,SortedSet<T>,ObservableCollection<T>) are not supported and are reported by CG004/CG005. - Relationship collections may contain only simple element types; complex values and collections of complex values are supported on nodes only.
- Spatial types (with provider support):
Cvoya.Graph.Point - "Complex" (as defined by CVOYA Graph): a user-defined
classorstructcomposed of supported properties. Astructis valid only as a nested complex value; graph entities themselves (INode/IRelationship) must be reference types (CG014).
Native-sized integers (IntPtr/UIntPtr, including the nint/nuint aliases) are not supported
anywhere in an entity's property graph: as ordinary properties, key values, nullable values,
collection elements, or members of complex property values.
The same root-and-nested rule rejects dictionaries, delegates, and every type declared under
System.Threading.Tasks, System.IO, System.Net, System.Reflection, or System.Runtime —
including the enums and structs those namespaces declare. Inherited public instance properties
participate in validation. Static properties, indexers, and properties marked
[Property(Ignore = true)] are not serialized, and the rules above ignore them: they never trigger
CG004/CG005, a runtime schema failure, or serializer suppression.
public record Person : Node
{
public string Name { get; set; }
public int Age { get; set; }
public double Height { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public string[] Tags { get; set; }
public List<string> Emails { get; set; }
public HashSet<string> Interests { get; set; }
public Point Location { get; set; } // Spatial data
}The [Relationship] attribute configures how relationship classes map to graph edges by assigning
their stored relationship type:
[Relationship("KNOWS")]
public record Knows : Relationship
{
[Property(Label = "since_date", IsIndexed = true)]
public DateTime Since { get; set; }
}Relationships do not own endpoints or direction. RelationshipDirection is command intent passed
to relationship creation, relative to the source/target arguments:
public enum RelationshipDirection
{
Outgoing, // Stored source -> target (default)
Incoming // Stored target -> source
}[Relationship("FOLLOWS")]
public record Follows : Relationship;
var follower = graph.Nodes<Person>().Where(person => person.Email == followerEmail);
var followed = graph.Nodes<Person>().Where(person => person.Email == followedEmail);
// Default physical orientation: follower -> followed.
await graph.CreateRelationshipAsync(follower, new Follows(), followed);
// Reverse the physical orientation without adding state to Follows.
await graph.CreateRelationshipAsync(
follower,
new Follows(),
followed,
RelationshipDirection.Incoming);Use GraphTraversalDirection.Both on traversal queries when you want to traverse matching stored
edges in either physical direction. Inspect IGraphPathSegment.Direction when a query result must
report physical orientation relative to its returned start/end nodes.
Complex CLR properties become first-class value nodes connected by a relationship whose type defaults to the property name. Override that graph name when the domain calls for a different semantic edge:
public record Person : Node
{
[ComplexProperty(RelationshipType = "LIVES_AT")]
public Address Home { get; init; } = new();
}The resulting structure is (:Person)-[:LIVES_AT]->(:Address). The attribute changes the relationship
mapping only; the CLR property name and serialized value-node label are unchanged.
CVOYA Graph requires provider implementors to support, if possible, materializing object instances as the type used during serialization. Consider the following type hierarchy:
[Node("Asset")]
public record Asset : Node
{
public string Name { get; set; } = string.Empty;
public decimal Value { get; set; }
}
[Node("Vehicle")]
public record Vehicle : Asset
{
public string VIN { get; set; } = string.Empty;
public int Year { get; set; }
}
[Node("RealEstate")]
public record RealEstate : Asset
{
public string Address { get; set; } = string.Empty;
public double SquareFeet { get; set; }
}We can store a RealEstate instance even though the variable that holds it is of type Asset.
Asset realEstate = new RealEstate
{
Name = "Office",
Value = 1_250_000m,
Address = "123 Main St",
SquareFeet = 2_400
};
await graph.CreateNodeAsync(realEstate);The underlying provider serializes the instance of the actual instance, which in this case is RealEstate. It stores enough metadata to know the type to be used when retrieving the node from the graph. If a different process, with a completely different type hierarchy is used to retrieve the graph node, the node's label is used in an attempt to identify the right type. In the above case, a type which has been annotated with the attribute Node("RealEstate") will be discovered and the deserialization will be attempted. If the type isn't compatible or a type annotated with that specific label isn't discovered, that is considered a runtime exception.
When materializing a stored node, the provider resolves the .NET type in this order:
- Stored metadata (exact). Each entity is persisted with its concrete .NET type name. If that type is loadable in the reading process and is assignable to the requested type, it is used directly — an exact round-trip.
- Label (portable). If the metadata type is not loadable (a different application, or the type was renamed or moved) or is not assignable to the requested type, the node's label is used to find a compatible local type. Because a label maps to exactly one type per process, this is deterministic. The requested type scopes the search, so
Nodes<T>()materializes the node as a compatible local type. - Fallback. Otherwise the requested type itself is used. For untyped reads,
DynamicNodealways succeeds, exposing the raw labels and properties.
This is why the explicit label is a durable contract: the metadata pointer is a fast path that is allowed to miss, and the label recovers the type across processes and across refactors.
Polymorphic queries rely on the class hierarchy rather than on multiple labels. Nodes<Asset>() matches not only :Asset but also :Vehicle and :RealEstate — at query-construction time the hierarchy is expanded to the set of compatible labels (each concrete subtype contributes its own single label). Only subtypes the registry has discovered (their assembly is loaded) participate.
- Use Meaningful Names: Choose clear, descriptive names for nodes and relationships
- Be Consistent: Establish naming conventions and stick to them
- Document Relationships: Use XML comments to document complex relationships
- Avoid Over-Attribution: Only add attributes when the default behavior isn't sufficient
- Consider Performance: Indexes and constraints can significantly impact performance