
Graph-Native Data Structures in C#, Part 1: Vertices, Edges and Properties
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 the relationships in your data matter, friends of friends, product recommendations, fraud rings, dependency trees, a relational schema works right up until it does not. The JOINs multiply, query plans get heavier, and by the tenth self join senior engineers are drawing graph diagrams on a whiteboard anyway. This series skips that middle stage and goes straight to building graph aware C# applications on NebulaGraph 3.7.0 (Community Edition, Apache 2.0).
Part 1 sets up the plumbing every later part depends on: the NebulaGraph schema model, the nebula-net C# client, a small explicit mapping layer, and the three queries you will write over and over.
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.
1. The Mental Shift: From Rows to Vertices, Edges and Properties
A relational database gives you tables with fixed schemas, rows as instances, and foreign keys as relationships hidden inside the data. A graph database turns that around. The relationship is a first class thing with its own schema and its own 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 three hop friend of friend query that needs three self joins in SQL is one GO 3 STEPS FROM statement in nGQL. Graph models earn their place when the query is relational in the graph theory sense: recommendations, access control hierarchies, supply chains, knowledge graphs.
The cost is real too. No ad hoc aggregations, a thin tooling ecosystem, manual mapping because there is no EF Core equivalent yet, and one more thing to operate. 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 runs 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 does not register itself in v3.0 and later. You have to run:
ADD HOSTS "storaged":9779;Skip that and every write either fails quietly or times out. It is the most common thing to get stuck on when you first boot the stack.
A Graph Space is like a MySQL database. Create one and USE it before you define any schema:
CREATE SPACE IF NOT EXISTS shop (
vid_type = FIXED_STRING(64),
partition_num = 1,
replica_factor = 1
);
USE shop;The VID type cannot be changed. Once you pick INT64 or FIXED_STRING(N), changing it means recreating the space. For C# domain keys, FIXED_STRING with a readable composite key such as "user:42" or "product:SKU-99" is the better default. It survives debugging, logging and correlation across systems. INT64 is slightly faster in storage but tells you nothing in a log line.
Tags and Edge Types
A Tag is a typed set of properties attached to a vertex, not a table. One vertex can carry several Tags. Since 3.3.0 every vertex must have at least one, and an INSERT VERTEX with no tag is 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 properties of a directed edge. Every edge also has an immutable rank, a 64 bit signed integer that defaults to 0, which separates multiple edges of the same type between the same two vertices. You need it as soon as a user can buy the same product twice.
3. Setting Up the C# Client
The community .NET client is NebulaNet on NuGet (nebula-contrib/nebula-net), version 3.0.0, published in March 2022. It targets netstandard2.0 and 2.1 and works 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 unreleased fixes before you ship 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, take a session per unit of work, and release it yourself. The client does not implement IDisposable or IAsyncDisposable, so a using block will not help. 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 takes 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 thin and explicit. That is the right choice here, not a compromise. Two directions need mapping: writing, POCO to nGQL string, and reading, result columns to POCO.
The client has no parameterised query API like ADO.NET's @param, so write a small 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, make the nGQL column aliases match your POCO property names exactly. The client maps by name:
public record UserDto(string Id, string Name, string Email);The matching YIELD clause is YIELD id(v) AS Id, properties(v).name AS Name, properties(v).email AS Email.
5. The Three Queries You Will Use All Series
INSERT: Writing Vertices and Edges
Use the helpers from section 4 and run them like this:
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 if you have used Neo4j. It costs more to parse but it expresses complex patterns better:
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
This is the NebulaGraph native form and the fastest way to do multi hop traversal. Watch the reserved keyword rule: edge has to 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 (shortened)
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; through nebula-console or the Graph Service TCP port, then run 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-99That covers the whole loop: vertex insert, edge insert, and a GO OVER hop query.
7. What Is Coming in the Series
Every later part builds on the GraphRepository, the NQL helper and the Docker Compose setup from here:
- Part 2, schema migrations. Evolving Tags and Edge Types with no downtime, and mapping a legacy relational schema onto the graph model.
- Part 3, bulk import. Seeding millions of vertices and edges with NebulaGraph's
IMPORTtool driven from C#. - Part 4, advanced traversal. Variable length hops, path analysis and
FIND SHORTEST PATH. - Part 5, AI integration. Hybrid graph, vector and text retrieval with NebulaGraph Enterprise 5.2 and embedding models from the .NET AI libraries.
The foundation is deliberately small. No heavy abstraction, no ORM magic, and mapping code you can read and step through. Graph databases reward engineers who know what their query is actually doing, and that starts here.
Keep reading

September 30, 2026 · 10 min
WebMCP: Letting Agents Call Your Web App Instead of Clicking It
Agents interact with your site by reading the DOM and guessing which element to click. WebMCP lets the page declare named tools instead. Here is the real API, the security model, and how it fits next to an MCP server in C#.
Read
September 29, 2026 · 7 min
Your AI Cost Model Is Already Wrong: Tokenizers, Context Cliffs and Session Hours
Claude 4.7 and later use a new tokenizer that produces about 30 percent more tokens for the same text. The price per million went down. The number of millions went up.
Read
September 28, 2026 · 7 min
Building an Agentic System in .NET, Part 4: Keeping Memory True
Stale agent memory is not just wasteful, it causes real failures that are hard to trace. This part builds the whole defence: timestamps, decay scoring, deduplication, contradiction detection, verification on read, and scheduled pruning as .NET hosted services.
Read