All articles
Databases/July 16, 2026/6 min read

Graph-Native Data Structures in C#, Part 4 Graphs & Adjacency with a Social Follow Network

Part 4 of the series models a directed social follow network in NebulaGraph, then surfaces neighbour queries, mutual connections, friend-of-friend suggestions, and super-node protection as typed C# async methods.

If you've modelled a tree or a list, you've done the warm-up. The real reason graph databases exist is for adjacency at scale — the kind of traversal that makes a relational database cry when you hit three joins deep. Social networks are the canonical case, and follow relationships are graph thinking in its purest form: directed edges, variable-depth hops, set intersections, and the ever-present super-node lurking behind every celebrity account.

This is Part 4 of the series. Part 1 established the BaseGraphRepository, the NebulaNet connection pool, and the VID naming convention ('type:id'). The same foundations apply here — we're swapping the movie/actor schema for user/follows, but the structural patterns are identical.

All nGQL snippets target NebulaGraph 3.x Community Edition. If you're on Enterprise v5.x you can use ISO/IEC 39075 GQL MATCH equivalents; the logic translates directly.

Modelling the Follow Network

Schema

CREATE SPACE IF NOT EXISTS socialnet(vid_type=FIXED_STRING(30));
USE socialnet;
 
CREATE TAG IF NOT EXISTS user(handle STRING, name STRING);
CREATE EDGE IF NOT EXISTS follows(since INT);

Vertexes carry a user tag. The VID convention is 'user:' + handle'user:ada', 'user:bob', etc. Keep handles short enough that the full VID fits inside FIXED_STRING(30) (the prefix consumes five characters, leaving 25 for the handle). Edge direction is physical: follows goes follower → followee.

Directed vs Symmetric: Follows vs Friends

follows is inherently asymmetric. Alice follows Bob, Bob may not follow back. That maps cleanly to a single directed edge.

Friends (mutual follow) are trickier. You have two options:

Option A — two edges by convention: when a friendship forms, insert (alice)-[:follows]->(bob) and (bob)-[:follows]->(alice). Reads are simple: GO FROM 'user:alice' OVER follows gives everyone Alice follows, and because Bob's reciprocal edge exists, it's in the set. The cost is 2× edge storage and two writes per friendship event.

Option B — one canonical edge + BIDIRECT: store only one direction (lower ID → higher ID by some rule) and always query with BIDIRECT. Writes are cheap; reads require discipline — every team member must remember to use BIDIRECT, and a plain GO OVER follows becomes a silent correctness bug.

For a follow network where "following" and "friends" are genuinely different concepts, Option A is almost always the right call. The extra storage is cheap; the semantic clarity is not.

Typed C# Queries: Followers and Following

The repository wrapper stays consistent with earlier parts in the series. One gotcha with nebula-net: session.Release() is not an IAsyncDisposable — wrap every session in a try/finally.

public sealed class FollowRepository : BaseGraphRepository
{
    public FollowRepository(NebulaPool pool) : base(pool) { }
 
    // Who does 'handle' follow?
    public async Task<List<string>> GetFollowingAsync(string handle)
    {
        var vid = $"'user:{handle}'";
        var nGql = $"""
            GO FROM {vid} OVER follows
            YIELD dst(edge) AS followeeVid
            """;
        return await ExecuteScalarListAsync<string>(nGql, "followeeVid");
    }
 
    // Who follows 'handle'?
    public async Task<List<string>> GetFollowersAsync(string handle)
    {
        var vid = $"'user:{handle}'";
        var nGql = $"""
            GO FROM {vid} OVER follows REVERSELY
            YIELD dst(edge) AS followerVid
            """;
        return await ExecuteScalarListAsync<string>(nGql, "followerVid");
    }
 
    // Out-degree: number of accounts 'handle' follows
    public async Task<long> GetFollowingCountAsync(string handle)
    {
        var vid = $"'user:{handle}'";
        var nGql = $"""
            GO FROM {vid} OVER follows
            YIELD count(*) AS degree
            """;
        return await ExecuteScalarAsync<long>(nGql, "degree");
    }
}

REVERSELY flips traversal to incoming edges — that's how you get followers without storing a reverse edge type. count(*) here counts traversed edges, which equals out-degree on a graph where each pair has at most one follows edge.

BFS, DFS, and Why You Must Always Bound Depth

nGQL's GO statement does walk traversal, not simple-path traversal. That means it can re-enter vertices on a cyclic graph. Run GO FROM 'user:ada' OVER follows without a step bound on a graph with mutual follows, and you have an infinite traversal waiting to happen.

Bound depth unconditionally:

GO 3 STEPS FROM 'user:ada' OVER follows YIELD DISTINCT dst(edge)

For variable-length, open-ended exploration you can use MATCH with a range: MATCH p=(v)-[:follows*1..3]->(w). The upper bound is not optional — omit it and the engine will refuse to execute or, on older versions, execute until it exhausts memory.

NebulaGraph Enterprise v5.2 advertises 100× faster path queries due to its in-database compute engine, but the fundamental rule holds on every version: an unbounded traversal on a real social graph with millions of edges is a denial-of-service attack against your own database.

For recommendation and social use-cases, depths beyond 3 rarely yield actionable signal and frequently produce result sets too large to be useful. Default to 2 for suggestions; allow 3 only with strict LIMIT and SAMPLE clauses.

Practical Queries: Mutual Connections and Friend Suggestions

public async Task<List<string>> GetMutualAsync(string handleA, string handleB)
{
    // Intersection of followees: people both A and B follow
    var vidA = $"'user:{handleA}'";
    var vidB = $"'user:{handleB}'";
    var nGql = $"""
        $a = GO FROM {vidA} OVER follows YIELD dst(edge) AS vid;
        $b = GO FROM {vidB} OVER follows YIELD dst(edge) AS vid;
        YIELD $a.vid AS mutual
        WHERE $a.vid IN $b.vid
        """;
    return await ExecuteScalarListAsync<string>(nGql, "mutual");
}
 
public async Task<List<string>> SuggestFollowsAsync(string handle, int limit = 20)
{
    // Classic 2-hop: who do my followees follow, that I don't already follow?
    var vid = $"'user:{handle}'";
    var nGql = $"""
        $already = GO FROM {vid} OVER follows YIELD dst(edge) AS vid;
        GO 2 STEPS FROM {vid} OVER follows
        YIELD DISTINCT dst(edge) AS candidate
        WHERE candidate != {vid}
          AND candidate NOT IN $already.vid
        | LIMIT {limit}
        """;
    return await ExecuteScalarListAsync<string>(nGql, "candidate");
}

A few things worth noting:

  • YIELD DISTINCT on the 2-hop query is essential. Multiple intermediate users may follow the same third party; without DISTINCT, that third party appears once per path, polluting downstream ranking logic.
  • The $already pipe captures current followees so we exclude them from suggestions. Without this filter, the most popular accounts in the user's neighborhood — almost certainly already followed — would dominate every suggestion list.
  • The WHERE candidate != {vid} guard stops the user appearing as a suggestion to themselves via a mutual-follow cycle.

Handling Super-Nodes with SAMPLE

Every social graph has celebrities: accounts with hundreds of thousands or millions of followers. Traversing all outgoing or incoming edges on one of these nodes per hop is a guaranteed latency spike and a compute bomb.

NebulaGraph's SAMPLE clause caps the edges explored per hop:

GO 2 STEPS FROM 'user:ada' OVER follows
YIELD DISTINCT dst(edge) AS candidate
SAMPLE [100, 50]

The list [100, 50] means: sample at most 100 edges at hop 1, then at most 50 edges at hop 2. The list length must match the hop countGO 2 STEPS requires exactly two values. This is a runtime error if you get it wrong, not a compile-time one, so validate this in your repository wrapper.

public async Task<List<string>> SuggestFollowsSafeAsync(
    string handle, int limit = 20, int[] samplePerHop = null)
{
    samplePerHop ??= [100, 50];
    if (samplePerHop.Length != 2)
        throw new ArgumentException(
            "samplePerHop must have exactly 2 values for a 2-hop traversal.",
            nameof(samplePerHop));
 
    var vid = $"'user:{handle}'";
    var sampleClause = $"SAMPLE [{samplePerHop[0]}, {samplePerHop[1]}]";
    var nGql = $"""
        $already = GO FROM {vid} OVER follows YIELD dst(edge) AS vid;
        GO 2 STEPS FROM {vid} OVER follows
        YIELD DISTINCT dst(edge) AS candidate
        WHERE candidate != {vid}
          AND candidate NOT IN $already.vid
        {sampleClause}
        | LIMIT {limit}
        """;
    return await ExecuteScalarListAsync<string>(nGql, "candidate");
}

Sampling introduces statistical bias — you won't see every 2-hop candidate — but for a suggestion feature that bias is acceptable and often desirable. Consistently surfacing a random sample of second-degree neighbours feels more alive than an exhaustively ranked deterministic list, and it keeps p99 latency stable regardless of who's in the traversal path.

Reachability Check

A lighter variant: can user A reach user B within N hops at all? Useful for "connection degree" features ("You and Bob have a 2nd-degree connection"):

GO 1 TO 3 STEPS FROM 'user:ada' OVER follows
YIELD dst(edge) AS reached
| WHERE $-.reached == 'user:bob'
| LIMIT 1

GO 1 TO 3 STEPS explores all hops from 1 to 3 inclusive in a single query, stopping at the first successful match when combined with LIMIT 1. In C#, map this to a Task<bool> by checking whether the result list is non-empty.

What's Next

Part 5 will move from traversal to ranking — adding weight properties to edges and using those weights to surface quality-adjusted recommendations rather than raw hop-count proximity. The follows edge already has a since field; we'll add an interaction_score and show how edge-property filtering shifts query strategy substantially.

The SuggestFollowsAsync method built here will become the starting point for that ranking layer.

Sources

  1. GO - Nebula Graph Database Manual
  2. GO - NebulaGraph Database Manual
  3. nGQL cheatsheet - NebulaGraph Database Manual
  4. nGQL Overview - Nebula Graph Database Manual
  5. Step 5 Use nGQL (CRUD) - NebulaGraph Database Manual
  6. SQL & nGQL - Nebula Graph Database Manual
  7. Gremlin & nGQL - Nebula Graph Database Manual
  8. NebulaGraph Query Language (nGQL)
Share