Best Practices

Note: To use Graph Model, 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

This guide covers best practices for using Graph Model effectively in your applications.

Model Design

1. Use Base Classes for Node and Relationship Implementation

Do: Inherit from Node or Relationship base classes

// Good: Using base class
[Node("Person")]
public record Person : Node
{
    public string Name { get; set; } = string.Empty;
    public int Age { get; set; }
}

// Good: Using Relationship base class
[Relationship("KNOWS")]
public record Knows(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId)
{
    public DateTime Since { get; set; }
}

Don’t: Implement INode or IRelationship directly

// Avoid: Implementing interface directly (triggers CG011 warning)
public record Person : INode
{
    // CG011 warns on direct INode implementations; inherit from Node unless you need full control.
    public string Id { get; init; } = Guid.NewGuid().ToString();
    public IReadOnlyList<string> Labels { get; } = new List<string> { "Person" }; // Don't manage these manually!
    public string Name { get; set; } = string.Empty;
}

Why? The base classes provide:

  • Automatic ID generation
  • Runtime metadata properties (Labels, Type) managed by the graph provider
  • Correct initialization patterns
  • Protection against manual metadata manipulation

The Labels property on INode and Type property on IRelationship are runtime metadata populated by the graph provider during serialization/deserialization. They enable polymorphic queries and filtering but should never be set manually.

// Querying with runtime metadata
var memorySegments = await graph.Nodes<User>()
    .Where(u => u.Id == userId)
    .PathSegments<User, UserMemory, Memory>()
    .Where(ps => ps.EndNode.Id == memoryId && ps.Relationship.Type == relationshipType) // Using runtime Type property
    .ToListAsync();

2. Choose the Right Granularity

Do: Model entities at the right level of detail

// Good: Separate entities for different concerns
[Node("Person")]
public record Person : Node
{
    public string Name { get; set; } = string.Empty;
}

[Node("Address")]
public record Address : Node
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
}

[Relationship("LIVES_AT")]
public record LivesAt(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId)
{
    public DateTime Since { get; set; }
}

Don’t: Embed complex objects as properties

// Avoid: Complex nested properties
public record Person : Node
{
    public Address HomeAddress { get; set; } // This won't work well
}

3. Use Meaningful Relationship Types

Do: Use descriptive, domain-specific relationship names

[Relationship("REPORTS_TO")]
public record ReportsTo(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId);

[Relationship("PURCHASED")]
public record Purchased(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId)
{
    public DateTime PurchaseDate { get; set; }
    public decimal Amount { get; set; }
}

Don’t: Use generic relationship names

// Avoid: Too generic
[Relationship("RELATED_TO")]
public record RelatedTo(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId);

4. Model Time Appropriately

Do: Add temporal properties to relationships when needed

[Relationship("WORKED_AT")]
public record WorkedAt(string StartNodeId, string EndNodeId) : Relationship(StartNodeId, EndNodeId)
{
    public DateTime StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string Position { get; set; } = string.Empty;

    public bool IsCurrent => EndDate == null;
}

Query Patterns

1. Use Projections to Reduce Data Transfer

Do: Project only the data you need

// Good: Only fetch required fields
var names = await graph.Nodes<Person>()
    .Where(p => p.Department == "Sales")
    .Select(p => new { p.FirstName, p.LastName })
    .ToListAsync();

Don’t: Fetch entire entities when you only need a few properties

// Avoid: Fetching entire entities
var people = await graph.Nodes<Person>()
    .Where(p => p.Department == "Sales")
    .ToListAsync();
var names = people.Select(p => p.FirstName); // Inefficient

2. Filter Early and Specifically

Do: Apply filters as early as possible in the query

// Good: Filter at the database level
var results = await graph.Nodes<Order>()
    .Where(o => o.Status == "Pending" && o.Amount > 1000)
    .Take(10)
    .ToListAsync();

Don’t: Filter in memory after fetching data

// Avoid: Filtering in memory
var allOrders = await graph.Nodes<Order>().ToListAsync();
var results = allOrders
    .Where(o => o.Status == "Pending" && o.Amount > 1000)
    .Take(10);

3. Control Traversal Depth

Do: Explicitly set traversal depth based on your needs

// Good: Traverse only what you need
var peopleWithFriends = await graph.Nodes<Person>()
    .Where(p => p.City == "Seattle")
    .Traverse<Knows, Person>(1)
    .ToListAsync();

Don’t: Load deep graphs when not needed

// Avoid: Traversing too broadly
var everyone = await graph.Nodes<Person>()
    .Traverse<Knows, Person>(1, 10)
    .ToListAsync(); // May be huge!

Transaction Management

1. Keep Transactions Short

Do: Minimize transaction scope

// Good: Focused transaction
public async Task TransferEmployee(string employeeId, string newDeptId)
{
    await using var tx = await graph.GetTransactionAsync();

    var employee = await graph.GetNodeAsync<Employee>(employeeId, transaction: tx);
    var newDept = await graph.GetNodeAsync<Department>(newDeptId, transaction: tx);

    // Update the relationship
    await graph.DeleteRelationshipAsync(employee.CurrentDeptRelationshipId, transaction: tx);
    var newRel = new WorksIn(employee.Id, newDept.Id);
    await graph.CreateRelationshipAsync(newRel, transaction: tx);

    await tx.CommitAsync();
}

Don’t: Include unrelated operations in transactions

// Avoid: Long-running transaction
await using var tx = await graph.GetTransactionAsync();
var data = await FetchFromExternalApi(); // Don't do this in a transaction!
await ProcessData(data);
await graph.CreateNodeAsync(person, transaction: tx);
await tx.CommitAsync();

2. Handle Failures Gracefully

Do: Implement proper error handling

public async Task<bool> SafeCreatePerson(Person person)
{
    try
    {
        await using var tx = await graph.GetTransactionAsync();

        // Check for duplicates
        var existing = await graph.Nodes<Person>(transaction: tx)
            .FirstOrDefaultAsync(p => p.Email == person.Email);

        if (existing != null)
        {
            return false; // Already exists
        }

        await graph.CreateNodeAsync(person, transaction: tx);
        await tx.CommitAsync();
        return true;
    }
    catch (GraphException ex)
    {
        logger.LogError(ex, "Failed to create person");
        throw;
    }
}

Performance Optimization

1. Batch Operations

Do: Group related operations

// Good: Batch create
public async Task ImportPeople(List<PersonData> peopleData)
{
    const int batchSize = 100;

    for (int i = 0; i < peopleData.Count; i += batchSize)
    {
        var batch = peopleData.Skip(i).Take(batchSize);

        await using var tx = await graph.GetTransactionAsync();
        foreach (var data in batch)
        {
            var person = new Person { /* map from data */ };
            await graph.CreateNodeAsync(person, transaction: tx);
        }
        await tx.CommitAsync();
    }
}

2. Optimize Relationship Queries

Do: Query relationships efficiently

// Good: Direct relationship query
var knows = await graph.Relationships<Knows>()
    .Where(k => k.StartNodeId == personId || k.EndNodeId == personId)
    .ToListAsync();

Don’t: Load all nodes to find relationships

// Avoid: Inefficient relationship discovery
var allPeople = await graph.Nodes<Person>()
    .ToListAsync();
var allRelationships = await graph.Relationships<Knows>()
    .ToListAsync();
var personRelationships = allRelationships
    .Where(k => k.StartNodeId == personId || k.EndNodeId == personId)
    .ToList();

Error Handling

1. Distinguish Error Types

try
{
    await graph.CreateNodeAsync(node);
}
catch (GraphException ex)
{
    // Handle general graph errors
    logger.LogError("Graph operation failed: {Message}", ex.Message);
}
catch (Exception ex)
{
    // Handle unexpected errors
    logger.LogError(ex, "Unexpected error");
    throw;
}

2. Implement Retry Logic

public async Task<T> ExecuteWithRetry<T>(
    Func<Task<T>> operation,
    int maxRetries = 3)
{
    for (int i = 0; i < maxRetries; i++)
    {
        try
        {
            return await operation();
        }
        catch (GraphException) when (i < maxRetries - 1)
        {
            // Retry on transaction conflicts
            await Task.Delay(TimeSpan.FromMilliseconds(100 * (i + 1)));
        }
    }
    throw new Exception("Operation failed after retries");
}

Testing

1. Use Interfaces for Testability

public class PersonService
{
    private readonly IGraph graph;

    public PersonService(IGraph graph)
    {
        this.graph = graph;
    }

    public async Task<Person> GetPersonWithFriends(string id)
    {
        return await graph.GetNodeAsync<Person>(id);
    }
}

// In tests, pass your IGraph test double to the service constructor.

2. Test Transactions

[Fact]
public async Task Transaction_RollsBackOnError()
{
    await using var tx = await graph.GetTransactionAsync();

    var person = new Person { FirstName = "Test" };
    await graph.CreateNodeAsync(person, transaction: tx);

    // Don't commit - simulate error
    await tx.RollbackAsync();

    // Verify person wasn't created
    var exists = await graph.Nodes<Person>()
        .AnyAsync(p => p.FirstName == "Test");
    Assert.False(exists);
}

Security Considerations

1. Validate Input

public async Task<Person> CreatePerson(PersonInput input)
{
    // Validate input
    if (string.IsNullOrWhiteSpace(input.Email))
        throw new ArgumentException("Email is required");

    if (!IsValidEmail(input.Email))
        throw new ArgumentException("Invalid email format");

    // Sanitize data
    var person = new Person
    {
        FirstName = input.FirstName.Trim(),
        LastName = input.LastName.Trim(),
        Email = input.Email.ToLowerInvariant().Trim()
    };

    await graph.CreateNodeAsync(person);
    return person;
}

2. Implement Access Control

public async Task<IEnumerable<Document>> GetUserDocuments(string userId)
{
    // Only return documents the user has access to
    return await graph.Nodes<Document>()
        .Where(d => d.OwnerId == userId || d.IsPublic)
        .ToListAsync();
}

Monitoring and Maintenance

1. Log Important Operations

public async Task<Person> CreatePersonWithLogging(Person person)
{
    using (logger.BeginScope(new { PersonEmail = person.Email }))
    {
        logger.LogInformation("Creating person");

        var stopwatch = Stopwatch.StartNew();
        await graph.CreateNodeAsync(person);

        logger.LogInformation("Person created in {ElapsedMs}ms",
            stopwatch.ElapsedMilliseconds);

        return person;
    }
}

2. Monitor Query Performance

public async Task<List<T>> ExecuteQueryWithMetrics<T>(
    IQueryable<T> query,
    string queryName)
{
    using (var activity = Activity.StartActivity(queryName))
    {
        var stopwatch = Stopwatch.StartNew();
        var results = await query.ToListAsync();

        activity?.SetTag("query.duration", stopwatch.ElapsedMilliseconds);
        activity?.SetTag("query.count", results.Count);

        if (stopwatch.ElapsedMilliseconds > 1000)
        {
            logger.LogWarning("Slow query {QueryName} took {Duration}ms",
                queryName, stopwatch.ElapsedMilliseconds);
        }

        return results;
    }
}

3. Regular Maintenance

  • Review and optimize slow queries
  • Update indexes based on query patterns
  • Clean up orphaned relationships
  • Archive old data when appropriate

Summary

Following these best practices will help you build robust, performant, and maintainable applications with Graph Model. Remember to:

  • Design your model thoughtfully
  • Write efficient queries
  • Use transactions appropriately
  • Handle errors gracefully
  • Test thoroughly
  • Monitor performance
  • Keep security in mind