
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 DISTINCTon the 2-hop query is essential. Multiple intermediate users may follow the same third party; withoutDISTINCT, that third party appears once per path, polluting downstream ranking logic.- The
$alreadypipe 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 count — GO 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 1GO 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
- GO - Nebula Graph Database Manual
- GO - NebulaGraph Database Manual
- nGQL cheatsheet - NebulaGraph Database Manual
- nGQL Overview - Nebula Graph Database Manual
- Step 5 Use nGQL (CRUD) - NebulaGraph Database Manual
- SQL & nGQL - Nebula Graph Database Manual
- Gremlin & nGQL - Nebula Graph Database Manual
- NebulaGraph Query Language (nGQL)
Keep reading

July 13, 2026 · 7 min
Graph-Native Data Structures in C#, Part 3 — Linked Lists & Sequences as Edges
Model append-only event streams and version chains as linked-list graphs in NebulaGraph, then walk, mutate, and guard them safely from C# using idempotent VIDs and native nGQL GO traversals.
Read
June 26, 2026 · 7 min
Graph-Native Data Structures in C#, Part 2 — Trees & Hierarchies with NebulaGraph
Relational adjacency lists and nested sets buckle under real hierarchy workloads. This article shows how to model, query, and mutate category trees in NebulaGraph using nGQL and the nebula-net C# client — with cycle guards built into your application layer.
Read
June 26, 2026 · 6 min
Two Bugs Hiding in Our OpenTelemetry Pipeline: CORS Preflights and the NUL Byte That Killed a Whole Batch
Two silent production bugs in an OTLP-based observability pipeline — one blocking all browser telemetry clients, one dropping entire log batches — exposed how quickly boundary gaps become blind spots. Here's the root cause, the fix, and the repro for each.
Read