
Graph-Native Data Structures in C#, Part 1 — Vertices, Edges, and Properties from the Ground Up
Stop thinking in rows and tables. This first article in the series establishes the NebulaGraph data model, the C# client patterns, and the mapping layer you will reuse across every subsequent part.
Part 1 of the Graph-Native Data Structures in C# Series
If your data has relationships that matter — friends-of-friends, product recommendations, fraud rings, dependency trees — a relational schema will work until it won't. JOINs multiply, query plans balloon, and the tenth self-join is where senior engineers start drawing graph diagrams on whiteboards anyway. This series skips that middle part and goes straight to building graph-aware C# applications against NebulaGraph 3.7.0 (Community Edition, Apache 2.0).
Part 1 lays the plumbing every later part depends on: the NebulaGraph schema model, the nebula-net C# client, a minimal explicit mapping layer, and the three query patterns you will use constantly.
Later in the series: Part 2 covers schema migrations (from relational to graph), and Part 3 covers bulk-import pipelines for seeding large graphs from existing data sources.
1. The Mental Shift: From Rows to Vertices, Edges, and Properties
A relational database gives you tables (fixed schemas), rows (instances), and foreign keys (implicit relationships encoded as data). A graph database inverts the emphasis: relationships are first-class citizens with their own schemas and properties.
| Relational concept | NebulaGraph equivalent |
|---|---|
| Database | Graph Space |
| Table | Tag (on a vertex) or Edge Type |
| Row | Vertex (one or more Tags) or Edge |
| Column | Property on a Tag or Edge Type |
| Foreign key | The edge itself |
The payoff is traversal. A 3-hop friend-of-friend query that requires three self-joins in SQL is a single GO 3 STEPS FROM statement in nGQL. Graph models earn their keep when the query pattern is inherently relational in the graph-theory sense: recommendations, access-control hierarchies, supply chains, knowledge graphs.
The cost is real: no ad-hoc aggregations, no mature tooling ecosystem, manual mapping (no EF Core equivalent yet), and an extra operational component. If your data is genuinely tabular and your queries are mostly filters and projections, stay in SQL.
2. Schema-First in NebulaGraph: Tags, Edge Types, and VID Design
Graph Spaces and the ADD HOSTS Gotcha
NebulaGraph has three services: the stateless Graph Service (nebula-graphd), the Raft-based Storage Service (nebula-storaged), and the Meta Service. After docker-compose up, the storage service will not register itself automatically in v3.0+. You must run:
ADD HOSTS "storaged":9779;Skip this and every write will silently fail or time out. It is the single most common first-boot stumbling block.
A Graph Space is analogous to a MySQL database. Create one and USE it before defining any schema:
CREATE SPACE IF NOT EXISTS shop (
vid_type = FIXED_STRING(64),
partition_num = 1,
replica_factor = 1
);
USE shop;VID type is immutable. Once set to INT64 or FIXED_STRING(N), it cannot be changed without recreating the space. For C# domain keys, FIXED_STRING with a composite human-readable key ("user:42", "product:SKU-99") is the better default — it survives debugging, logging, and cross-system correlation. INT64 is marginally faster at storage but opaque in logs.
Tags and Edge Types
A Tag is a typed property bag attached to a vertex — not a table. A vertex can carry multiple Tags. Since NebulaGraph 3.3.0, every vertex must have at least one Tag; tagless INSERT VERTEX statements are rejected.
CREATE TAG IF NOT EXISTS User (
name string,
email string,
created_at datetime
);
CREATE TAG IF NOT EXISTS Product (
title string,
price_cents int64,
sku string
);
CREATE EDGE IF NOT EXISTS Purchased (
purchased_at datetime,
quantity int32
);An Edge Type defines the property schema for directed edges. Every edge also has an immutable rank (64-bit signed integer, default 0) that disambiguates multiple edges of the same type between the same source and destination — essential when a user can purchase the same product more than once.
3. Setting Up the C# Client
The community .NET client is NebulaNet on NuGet (nebula-contrib/nebula-net), version 3.0.0 published March 2022. It targets netstandard2.0/2.1 and is compatible with .NET 9 and .NET 10 (LTS, supported until November 2028).
<PackageReference Include="NebulaNet" Version="3.0.0" />⚠️ The NuGet package has not been updated since March 2022. Check the GitHub HEAD for any unreleased fixes before shipping to production, and consider pinning to a specific commit in a private feed.
DI Registration and Session Lifecycle
NebulaPool is the connection pool. Register it as a singleton; acquire a session per logical unit of work and release it explicitly. The client does not implement IDisposable or IAsyncDisposable, so using blocks won't work — you must call session.Release() manually.
// Program.cs — .NET 10
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(sp =>
{
var pool = new NebulaPool();
pool.Init(
new List<NebulaNet.Storage.HostAddress>
{
new("localhost", 9669)
},
new NebulaPoolConfig { MinConnsSize = 2, MaxConnsSize = 10 });
return pool;
});
builder.Services.AddScoped<GraphRepository>();A scoped GraphRepository acquires and releases a session per HTTP request:
public sealed class GraphRepository : IAsyncDisposable
{
private readonly NebulaPool _pool;
private ISession? _session;
public GraphRepository(NebulaPool pool) => _pool = pool;
private async Task<ISession> GetSessionAsync()
{
if (_session is null)
_session = await _pool.GetSessionAsync("root", "nebula", false);
return _session;
}
// queries go here — see Section 5
public async ValueTask DisposeAsync()
{
// IAsyncDisposable gives us a clean hook even though the
// client itself has no disposable contract.
_session?.Release();
_session = null;
await Task.CompletedTask;
}
}4. Mapping POCOs to Graph Entities
There is no EF Core for NebulaGraph. Keep the mapping layer thin and explicit — that is the right call here, not a limitation. Two things need mapping: writing (POCO → nGQL string) and reading (result columns → POCO).
The client has no first-class parameterized query API analogous to ADO.NET's @param. Build a minimal escape helper to avoid injection and quoting bugs:
public static class NQL
{
// Escape a string value for embedding in nGQL.
public static string S(string value) =>
'"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
public static string Vid(string domainKey) => S(domainKey);
// Write a User vertex
public static string InsertUser(User u) =>
$"""
INSERT VERTEX User(name, email, created_at)
VALUES {Vid($"user:{u.Id}")}:(
{S(u.Name)},
{S(u.Email)},
datetime({S(u.CreatedAt.ToString("o"))})
);
""";
// Write a Purchased edge with a rank derived from UTC ticks
// so repeat purchases don't collide (rank is immutable per edge).
public static string InsertPurchasedEdge(
string userId, string productId,
DateTime purchasedAt, int quantity)
{
long rank = purchasedAt.Ticks;
return $"""
INSERT EDGE Purchased(purchased_at, quantity)
VALUES {Vid($"user:{userId}")}->{Vid($"product:{productId}")}@{rank}:(
datetime({S(purchasedAt.ToString("o"))}),
{quantity}
);
""";
}
}For reading, project nGQL column aliases to match your POCO property names exactly — the client maps by name:
public record UserDto(string Id, string Name, string Email);The corresponding YIELD clause: YIELD id(v) AS Id, properties(v).name AS Name, properties(v).email AS Email.
5. The Three Queries Reused All Series
INSERT — Writing Vertices and Edges
Use the helpers from Section 4. Execute with:
public async Task UpsertUserAsync(User user)
{
var session = await GetSessionAsync();
var result = await session.ExecuteAsync(NQL.InsertUser(user));
if (!result.IsSucceed())
throw new InvalidOperationException(
$"Insert failed: {result.GetErrorMessage()}");
}MATCH — openCypher-Style Lookup
Familiar to Neo4j/Cypher users; higher parse overhead but more expressive for complex patterns:
public async Task<List<UserDto>> FindUsersByEmailDomainAsync(string domain)
{
var session = await GetSessionAsync();
var nql = $"""
USE shop;
MATCH (v:User)
WHERE properties(v).email ENDS WITH {NQL.S("@" + domain)}
RETURN id(v) AS Id,
properties(v).name AS Name,
properties(v).email AS Email
LIMIT 50;
""";
var result = await session.ExecuteAsync(nql);
return await result.ToListAsync<UserDto>();
}GO … OVER — Native Hop Traversal
NebulaGraph-native; the fastest path for multi-hop traversal. Note the reserved-keyword alias rule: edge must be aliased.
public async Task<List<string>> GetPurchasedProductIdsAsync(string userId)
{
var session = await GetSessionAsync();
var nql = $"""
USE shop;
GO FROM {NQL.Vid($"user:{userId}")}
OVER Purchased
YIELD dst(edge) AS ProductVid;
""";
var result = await session.ExecuteAsync(nql);
var rows = await result.ToListAsync<dynamic>();
return rows.Select(r => (string)r.ProductVid).ToList();
}6. End-to-End Example: Users, Products, and a Purchased Edge
docker-compose.yml (abbreviated)
services:
metad:
image: vesoft/nebula-metad:v3.7.0
ports: ["9559:9559"]
storaged:
image: vesoft/nebula-storaged:v3.7.0
ports: ["9779:9779"]
depends_on: [metad]
graphd:
image: vesoft/nebula-graphd:v3.7.0
ports: ["9669:9669"]
depends_on: [metad, storaged]After docker-compose up -d, run ADD HOSTS "storaged":9779; via nebula-console or the Graph Service TCP port, then execute the schema DDL from Section 2.
Minimal Smoke Test
var user = new User { Id = "42", Name = "Ada", Email = "ada@example.com",
CreatedAt = DateTime.UtcNow };
var product = new Product { Id = "SKU-99", Title = "Graph Book",
PriceCents = 3999, Sku = "SKU-99" };
await repo.UpsertUserAsync(user);
await repo.UpsertProductAsync(product);
await repo.InsertPurchasedAsync("42", "SKU-99", DateTime.UtcNow, quantity: 1);
var purchased = await repo.GetPurchasedProductIdsAsync("42");
Console.WriteLine(purchased[0]); // product:SKU-99This confirms the full write-then-read loop: vertex insert, edge insert, and a GO … OVER hop query.
7. What's Coming in the Series
Every subsequent part builds on the GraphRepository, NQL helper, and Docker Compose setup established here:
- Part 2 — Schema Migrations: evolving Tags and Edge Types without downtime; mapping from a legacy relational schema to the graph model.
- Part 3 — Bulk Import: seeding millions of vertices and edges using NebulaGraph's
IMPORTtool driven from C# pipelines. - Part 4 — Advanced Traversal: variable-length hops, path analysis, and
FIND SHORTEST PATH. - Part 5 — AI Integration: graph-vector-text hybrid retrieval using NebulaGraph Enterprise 5.2 and embedding models from .NET AI libraries.
The foundation is intentionally minimal: no heavy abstraction, no ORM magic, explicit mapping you can read and debug. Graph databases reward engineers who understand what the query is actually doing — that starts here.
Keep reading

June 21, 2026 · 3 min
Introducing OpenPulse: Self-Hosted Observability with AI Root-Cause Analysis
Observability forces a bad trade-off: an expensive SaaS bill with your data offsite, or a DIY stack you babysit with no AI insight. OpenPulse is the third option — self-hosted logs, traces and metrics with AI root-cause analysis and auto-remediation built in.
Read
June 17, 2026 · 7 min
From ArangoDB to NebulaGraph: Why Cost Forced the Move — and Performance Made It Permanent
When ArangoDB's October 2025 licensing change put our production cluster out of compliance overnight, we had 90 days to migrate a live graph workload to NebulaGraph. Here is exactly what we changed, what broke, and what we would do differently.
Read
June 15, 2026 · 7 min
How to Interview Junior Developers in the Age of AI
We're still interviewing juniors for 2015 — quizzing syntax AI now writes and banning the tools they'll use on day one. Here's what to test instead: problem-solving, business sense, tool fluency, and teamwork, with a scorecard you can steal.
Read