AWS Database Blog

Exploring type-safe .NET development for Amazon Neptune with Gremlinq

When you build graph database applications on Amazon Neptune using .NET, one of the early architectural decisions that you will make is how to construct and execute Apache TinkerPop based Gremlin queries. You can work directly with Gremlin’s traversal API, writing queries that use string-based property names and handling dynamic results. Alternatively, you can use an abstraction layer that provides wider support for compile-time type checking and strongly typed result objects. In making these decisions, you will want to understand your options and their trade-offs.

Type-safety in graph database development with .NET means catching certain categories of errors, like misspelled property names, at compile time rather than discovering them during testing or in production. It also means working with familiar C# patterns like Language Integrated Query (LINQ) expressions and lambda syntax to build queries. The main trade-off is an additional abstraction layer between your code and the underlying Gremlin traversals.

ExRam.Gremlinq is an open source Object-Graph-Mapper (OGM) that provides a strongly typed development experience for TinkerPop-enabled graph databases. Since its creation in 2017, the maintainers actively develop it to translate C# queries into Gremlin traversals while preserving type information throughout the query pipeline. If you value compile-time verification and IDE-assisted development, or those working on large code bases where refactoring safety matters, Gremlinq offers an alternative worth evaluating.

In this post, we walk through how Gremlinq works, demonstrate its capabilities, show you how to set up a Neptune project with the provided templates, and help you understand where this approach might fit in your development context.

Working with Gremlin in .NET

Let’s first look at some characteristics of working with Gremlin.NET and how they relate to typical C# development patterns. When using Gremlin.NET directly, you generally construct queries as strings or through a fluent API that still requires string-based labels and property names:

// Gremlin.NET approach - string-based query
var query = "g.V().hasLabel('Airport').has('Code','SEA').out('Route').hasLabel('Airport')";
var result = await gremlinClient.SubmitAsync<dynamic>(query);
// Or using the fluent API - still requires string labels and property names
var traversal = g.V()
    .HasLabel("Airport")
    .Has("code", "SEA")
    .Out("Route")
    .HasLabel("Airport");

When you work directly with Gremlin.NET, you will notice a few characteristics that shape the development experience. If you misenter “Airport” as “aiport”, you will catch that at runtime rather than during compilation. The queries return dynamic objects, which means that you will handle manual casting and work without the compile-time guidance that strongly typed projections provide. Property names like “code” appear as strings throughout your code, so typos and refactoring require careful attention. Because the API doesn’t encode the current traversal shape in its type system, it’s possible to compose traversals that are syntactically valid after calling V() but might not make sense at that point in your query.

Results typically come back as dictionaries or dynamic objects that you then map into your domain classes:

var results = await gremlinClient.SubmitAsync<dynamic>(query);
foreach (var result in results)
{
    // Manual property extraction, which can be repetitive in larger applications
    var code = result["code"];
    var city = result["city"];
    // ... build your Airport object manually
}

This direct approach to query construction works well for many applications and, as you will see later, can sometimes be necessary. The next section introduces how Gremlinq works as an alternative.

Gremlinq: A Typed OGM for Gremlin

Gremlinq takes a different approach where it presents a type system that carries element information throughout the query, C# expression support for building traversals, and automatic, type‑safe deserialization of results. To understand how these capabilities work in practice, we examine the core features that enable this alternative development experience.

Type-safe domain modeling

With Gremlinq, you define your graph elements as plain C# classes:

public sealed class Airport : Vertex
{
    public string? Code { get; set; }
    public string? ICAO { get; set; }
    public string? City { get; set; }
    public string? Region { get; set; }
    public string? Country { get; set; }
    public string? Description { get; set; }
    public int Runways { get; set; }
    public int Elevation { get; set; }
    public double Latitude { get; set; }
    public double Longitude { get; set; }
    public int LongestRunway { get; set; }
}
public sealed class Route : Edge
{
    public long Distance { get; set; }
}

These are regular POCOs (Plain Old C# Objects) with no special attributes or base classes required beyond inheriting from a marker base type. The property names map directly to graph properties, and the class name becomes the vertex or edge label.

Fluent, type-aware queries

The previously mentioned Gremlin.NET query for routes out of Seattle would look like this in Gremlinq:

var destinationsFromSeattle = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>()
    .ToArrayAsync();

The Gremlinq version expresses the same traversal using strongly typed C# constructs with compile‑time type information throughout the query:

  • V<Airport>() returns an IVertexGremlinQuery<Airport>
  • The .Where() method accepts a lambda expression with IntelliSense for Airport properties
  • .Out<Route>() traverses edges of type Route
  • .OfType<Airport>() narrows the result to airports
  • The result is of type Airport[], an array of Airport. No casting needed

C# expression recognition

One of Gremlinq’s most important features is its ability to recognize standard C# expressions and translate them to the appropriate Gremlin steps. You write familiar C# code, and Gremlinq handles the translation:

// String operations
.Where(airport => airport.Code!.StartsWith("S"))    // Translates to TextP.startingWith()
// Numeric comparisons
.Where(route => route.Distance <= 1500)          // Translates to P.lte(1500)
// Equality checks
.Where(airport => airport.Code == "SEA")             // Translates to has('code', 'SEA')

This allows Gremlinq to translate many common C# expressions into the corresponding Gremlin predicates, like P.eq(), P.gte(), or TextP.containing(), so you can use familiar C# syntax while Gremlinq generates the underlying Gremlin steps.

IntelliSense-guided development

Because the query type changes based on the traversal steps that you’ve applied, your IDE can offer context-appropriate suggestions. After calling .V<Airport>(), IntelliSense shows you vertex-specific operations. After calling .OutE<Route>(), you see edge-specific operations. This guided experience can help you to discover valid traversal operations and to avoid certain categories of invalid query construction.

Prerequisites

In the following sections, we set up a basic Gremlinq project and show some example usage with Neptune. You need Microsoft .NET 10 or higher installed to run the project template and, if desired, a Neptune instance with the Air Routes dataset loaded to run the queries. One of the most direct ways to load that dataset is through the Graph Notebook.

Set up a Neptune project

Gremlinq includes .NET templates that scaffold a complete project with sample queries. As a next step, we walk through creating a console application targeting Amazon Neptune.

Installing the templates

First, install the Gremlinq project templates:

dotnet new install ExRam.Gremlinq.Templates

Creating a Neptune project

Create a new console project configured for Amazon Neptune:

dotnet new gremlinq-console --provider Neptune -n MyNeptuneApp
cd MyNeptuneApp

This generates a fully functional console application with sample queries using an airline routes domain model.

Configuring the Neptune connection

Open Program.cs and configure the connection to your Neptune endpoint:

using ExRam.Gremlinq.Core;
using ExRam.Gremlinq.Providers.Neptune;
using ExRam.Gremlinq.Support.NewtonsoftJson;
public class Program
{
    private readonly IGremlinQuerySource _g;
    public Program()
    {
        var endpoint = new Uri("wss://your-neptune-endpoint:port/gremlin");
        _g = GremlinQuerySource.g
            .UseNeptune<Vertex, Edge>(configurator => configurator
                .At(endpoint)
                .UseIAMAuthentication(iam => iam
                    .UseSigV4()
                    .WithUri(endpoint)
                    .WithRegion("us-east-1")
                    .WithCredentialsFromDefaultAWSCredentialsIdentityResolver())
                .UseNewtonsoftJson());    
    }
	// ... 
}

The configuration is fluent and type-safe without magic strings for configuration keys. We use the WithCredentialsFromDefaultAWSCredentialsIdentityResolver, which walks the standard AWS default credential chain to fetch AWS credentials for authentication. The .UseNeptune<Vertex, Edge>() call tells Gremlinq about your base vertex and edge types, enabling the type system’s validation features.

Exploring the sample queries

The generated project includes a variety of sample queries that demonstrate Gremlinq’s capabilities. In the following sections, we explore them in depth and will start by adding some data to Neptune. The template includes a method to populate your database with sample airport and route data:

await _g
    .CreateAirRoutesSmall();

This method is idempotent. It checks for existing data before inserting, so you can call it safely on every run during development.

Retrieving all airports:

To start, we recommend getting a list of all airports:

var airports = await _g
    .V<Airport>()
    .ToArrayAsync();

The semantics of this is straightforward: Start at all vertices of type Airport and materialize them as an array. After awaiting the query, the result is an object of type Airport[] with all members populated.

Filtering with C# expressions:

Because we don’t want to retrieve all the airports all the time, let’s apply a filter on the query that will only let those airports pass whose code starts with the letter ‘S’:

var airportCodesStartingWithLetterS = await _g
    .V<Airport>()
    .Where(airport => airport.Code!.StartsWith("S"))
    .ToArrayAsync();

There is something natural in how this feels in that you are using the familiar string.StartsWith() call and Gremlinq will translate it to the appropriate Gremlin text predicate under the hood.

Finding destinations from Seattle:

Graph databases excel at relationship traversals. Here’s how Gremlinq handles it:

var destinationsFromSeattle = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>()
    .ToArrayAsync();

The following listing breaks this query down step by step:

  1. .V<Airport>() – Start with airport vertices
  2. .Where(airport => airport.Code == "SEA") – Filter on Seattle
  3. .Out<Route>() – Traverse outgoing Route edges
  4. .OfType<Airport>() – Checks results are of type Airport (they will be in any case given the schema, but the explicit OfType confirms it and reestablishes type information)
  5. .ToArrayAsync() – Execute and return Airport[]

Finding incoming routes:

Swap .Out<Route>() for .In<Route>() to reverse the direction we’re walking:

var routesIntoSEA = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .In<Route>()
    .OfType<Airport>()
    .ToArrayAsync();

What we get is a list of all the airports that can reach SEA by a direct route.

Working with edge properties

When you need to filter or access edge properties, use .OutE() and .InE() to traverse to the edges themselves:

var within1500Miles = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .OutE<Route>()
    .Where(route => route.Distance <= 1500)
    .InV<Airport>()
    .ToArrayAsync();

This query:

  1. Starts at SEA
  2. Traverses to the outgoing Route edges (not directly to vertices)
  3. Filters routes where Distance <= 1500
  4. Traverses from those edges to their target airports

The type system tracks everything: after .OutE<Route>(), you have access to Route properties like Distance. After .InV<Airport>(), you’re back to working with airports.

Working with subqueries

Graph traversals become more interesting when you need to explore multiple path lengths. For example, finding airports reachable by one or two connecting flights with the help of the Union operator:

var withinOneOrTwoFlights = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Union(
        __ => __
            .Out<Route>(),
        __ => __
            .Out<Route>()
            .Out<Route>())
    .OfType<Airport>()
    .Dedup()
    .ToArrayAsync();

The Union operator runs multiple traversals in parallel and combines their results. Note the syntax for sub-queries: Each sub-query is denoted by a delegate, where in the case of the Union-operator (and most other operators dealing with sub-queries) the parameter of the delegate (__) is of the same query type as the query up to the call of Union (IVertexGremlinQuery<Airport> in this case). You can use this to deal with complete type information, even in a sub-query.

The Dedup() step removes duplicates (an airport reachable by one flight might also be reachable by two).

Filtering with sub-queries

Sometimes you must filter based on characteristics discovered by further traversal:

var destinationsWithRoutesToAtlanta = await _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>()
    .Where(__ => __
        .Out<Route>()
        .OfType<Airport>()
        .Where(airport => airport.Code == "ATL"))
    .ToArrayAsync();

This finds airports reachable from Seattle that also have a direct route to Atlanta. The inner .Where() accepts a sub-query, if and only if that query yields at least one result element, the outer element passes the filter.

Ordering results

This is where we introduce the first notion of some kind of Domain Specific Language (DSL) with Gremlinq itself: When ordering results, you can use the Gremlin language to call order-operators and by-modulators on the same level. Gremlinq, however, allows for a fluent ordering API:

var orderedByCode = await _g
    .V<Airport>()
    .Order(o => o
        .By(airport => airport.Code))
    .ToArrayAsync();

By entering Gremlinq’s Order DSL, we keep the By-modulators solely within this DSL. They won’t be available to you outside of this kind of DSL.

The second example orders airports by their longest route distance—a computed value that requires traversing to edges. Gremlinq handles this with nested order builders:

var orderedByLongestRoute = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance)))
    .ToArrayAsync();

This also works for multiple ordering criteria:

var orderedByLongestRouteThenCode = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance))
        .By(airport => airport.Code))
    .ToArrayAsync();

Limiting results

If result sets are expected to be large, we recommend that you apply some limitation on the size of the materialized results. Standard pagination operations are fully supported:

var fiveAirportsOrderedByLongestRoute = await _g
    .V<Airport>()
    .Order(o => o
        .ByDescending(__ => __
            .OutE<Route>()
            .Order(o => o
                .ByDescending(route => route.Distance))
            .Values(route => route.Distance)))
    .Limit(5)
    .ToArrayAsync();

Other pagination patterns can also be achieved with .Range(...), .Skip(...), and .Tail(...) having synonymous features to the Gremlin steps of the same names.

Step Labels: Capturing and referencing traversal state

In Gremlin, step labels are represented as strings that the compiler doesn’t validate, so developers typically track their meaning by convention. When using Gremlinq, there’s a generic StepLabel<..> type that encodes the element type and query type, making it possible to work with step labels in a type‑safe way.

The following query finds airports reachable from Seattle with exactly two flights but excludes Seattle itself from the results. The .As(...) method captures the current element (Seattle) in a StepLabel<IVertexGremlinQuery<Airport>, Airport>, which preserves not only element-type information but also query-type information. The As(...)-method handles the creation of such a StepLabel instance and passes it to the continuation, where it can be referenced later in the .Where() clause:

var withinTwoFlightsWithNoReturn = await _g
    .V<Airport>()
    .Where(departure => departure.Code == "SEA")
    .As((__, sea) => __
        .Out<Route>()
        .Out<Route>()
        .OfType<Airport>()
        .Where(destination => destination != sea.Value))
    .Dedup()
    .ToArrayAsync();

Projections

You can use Projections to shape query results into custom structures that go beyond dictionaries. This is useful when you need specific properties or computed values rather than entire vertices. There is a dedicated sub-DSL in Gremlinq for this:

var projectedTuple = await _g
    .V<Airport>()
    .Project(p => p
        .ToTuple()
        .By(x => x.Description!)
        .By(x => x.Code!)
        .By(__ => __.Out<Route>().Count()))
    .FirstAsync();

This returns tuples containing each airport’s code, city, and the count of outgoing routes. You can also project into anonymous types or custom Data Transfer Objects (DTOs) for more complex scenarios.

Aggregating with Fold and Unfold

Aggregation of elements yielded throughout a graph-walk is done using the fold-operator in Gremlin. Gremlinq’s .Fold()method mirrors this while keeping the original element and query-type information intact:

var fold = await _g
    .V<Airport>()
    .Map(__ => __
        .Out<Route>()
        .OfType<Airport>()
        .Fold())
    .ToArrayAsync();

The return type of Fold includes both the array element type and the underlying query type (for example, IArrayGremlinQuery<Airport[], Airport, IVertexGremlinQuery<Airport>> in this sample), so that subsequent unfolding can still work with rich type information.

var foldFilterUnfold = await _g
    .V<Airport>()
    .Map(__ => __
        .Out<Route>()
        .OfType<Airport>()
        .Fold()
        .Unfold()
        .Values(x => x.Code!))
    .ToArrayAsync();

Grouping

You can use grouping to organize results by a key, paired with the values that apply for that key. The following query groups all airports by the number of routes, and returns a dictionary where keys are integers and values are arrays of airports.

var groupByNumberOfRoutes = await _g
    .V<Airport>()
    .Group(g => g
        .ByKey(__ => __
            .OutE<Route>()
            .Count()))
    .ToArrayAsync();

Implicitly, unless specified differently, the values are of the same type as the source type (in our case, Airport). We can specify this to be the airport code rather than the airport itself:

var groupCodesByNumberOfRoutes = await _g
    .V<Airport>()
    .Group(g => g
        .ByKey(__ => __
            .OutE<Route>()
            .Count())
        .ByValue(__ => __
            .Values(x => x.Code!)))
    .ToArrayAsync();

Loops with Repeat

For recursive traversals like finding paths or exploring hierarchies, Gremlin allows specifying repeated loops along emit-directives and exit-conditions. Gremlinq users can enter a dedicated DSL for looping by calling the Loop-method and going from there:

var threeFlights = await _g
    .V<Airport>()
    .Map(__ => __
        .Loop(ls => ls
            .Repeat(__ => __
                .Out<Route>()
                .OfType<Airport>())
            .Times(3))
        .Dedup()
        .Values(x => x.Code!)
        .Fold())
    .ToArrayAsync();

You can also use .Until() to repeat until a condition is met, in this case, we walk Route-edges until we reach Atlanta (we restrict our result set by Limit(...) because the result set would become too big quickly):

var repeatEmitUntilAtlanta = await _g
    .V<Airport>()
    .Where(x => x.Code == "SEA")
    .Loop(ls => ls
        .Repeat(__ => __
            .Out<Route>()
            .OfType<Airport>())
        .Emit()
        .Until(__ => __
            .Where(a => a.Code == "ATL")))
    .Dedup()
    .Limit(10)
    .Values(x => x.Code!)
    .ToArrayAsync();

Tree queries

You can use the Tree-operator in Gremlinq to construct tree-shaped result sets representing traversal paths in a graph, similar to Gremlin’s tree step. While the standard Tree() method returns results with limited compile-time type guarantees, Gremlinq enhances static type-safety through its generic Tree() overload. By specifying the element type in Tree(), you verify that the tree contains values of the expected type throughout the traversal.

var untypedTree = await _g
    .V<Airport>()
    .Where(a => a.Code == "SEA")
    .Out<Route>()
    .Out<Route>()
    .Tree()
    .FirstAsync();
var typedTree = await _g
    .V<Airport>()
    .Where(a => a.Code == "SEA")
    .Out<Route>()
    .Out<Route>()
    .Tree<Airport>()
    .FirstAsync();

Additionally, type-safety can be further improved by using the Of and By modulators with Tree, allowing explicit selection of result projections and key types. These features can help catch certain type mismatches at compile time and make traversal expressions more predictable in strongly typed C#.

var typedTreeWithOf = await _g
    .V<Airport>()
    .OutE<Route>()
    .InV<Airport>()
    .Tree(_ => _
        .Of<Airport>()
        .Of<Route>()
        .Of<Airport>())
    .FirstAsync();
var typedTreeWithOfAndBy = await _g
    .V<Airport>()
    .Out<Route>()
    .OfType<Airport>()
    .Tree(_ => _
        .Of<Airport>().By(x => x.Code!)
        .Of<Airport>().By(x => x.Description!))
    .FirstAsync();

ASP.NET integration

You can use Gremlinq in web applications with its built-in support for ASP.NET Core with dependency injection (DI). A separate template is available:

dotnet new gremlinq-aspnet --provider Neptune -n MyNeptuneWebApp

This scaffolds a Web API project with IGremlinQuerySource registered in the DI container. Configuration is loaded from appsettings.json, and controllers can inject the query source:

[ApiController]
[Route("/airports")]
public class AirportsController : ControllerBase
{
    private readonly IGremlinQuerySource _g;
    public AirportsController(IGremlinQuerySource g)
    {
        _g = g;
    }
    [HttpGet]
    public async Task<IActionResult> Index() => Ok(await _g
         .V<Airport>()
         .ToArrayAsync());
    [HttpGet("/{airPortCode}")]
    public async Task<IActionResult> Single(string airPortCode)
    {
        var maybeAirport = await _g
            .V<Airport>()
            .Where(airport => airport.Code == airPortCode)
            .FirstOrDefaultAsync();
        return maybeAirport is { } airport
            ? Ok(airport)
            : NotFound();
    }
}

Gremlinq.Extensions

Beyond the open source core library, a set of commercial extensions is available on the Gremlinq website, where they add frequently requested features:

  • System.Text.Json deserialization: Use the modern .NET JSON serializer instead of Newtonsoft.Json
  • OpenTelemetry instrumentation: Integrate graph database tracing into your observability stack
  • Traversal strategies: Apply server-side traversal strategies to your queries
  • Groovy script execution: Execute raw Gremlin scripts when needed for advanced scenarios
  • Transactions: Transaction support (currently in development)

These extensions are licensed separately and can be added to your project as needed.

Performance and trade-offs

Gremlinq builds syntactically correct Gremlin traversals and submits them to Neptune, so query execution and optimization ultimately happen in Neptune’s Gremlin engine. In many cases, the strongly typed DSL produces traversals that correspond closely to what you would write by hand, and the usual approaches to monitoring and tuning query performance in Neptune still apply.

If you identify a performance issue that requires a traversal Gremlinq can’t express conveniently, you can fall back to raw Gremlin for that specific case while keeping the rest of your application in the strongly typed Gremlinq model. In those situations, it’s important to understand what Gremlinq is sending to Neptune so that you can compare the generated traversal with a hand‑tuned version and choose the approach that meets your performance and maintainability requirements.

Inspecting the generated Gremlin

If you want to see the underlying Gremlin query that Gremlinq is producing and sending to Neptune. You can use its Debug() method that shows the Gremlin traversal corresponding to a query, which you can log or run directly against Neptune when investigating performance issues:

// Example: inspect the Gremlin for a query
var query = _g
    .V<Airport>()
    .Where(airport => airport.Code == "SEA")
    .Out<Route>()
    .OfType<Airport>();
// Get the raw Gremlin (or bytecode representation, depending on provider)
var debugInfo = query.Debug();
// Log or print for inspection
Console.WriteLine(debugInfo);

By capturing this output, you can use the output in a variety of ways. It can be helpful in debugging because you might need the actual query sent to Neptune when trying to understand server logging. For example, the Neptune slow query log captures the Gremlin queries that Gremlinq generates, so being able to trace a query back to the Gremlinq code that produced it is essential for identifying improvement opportunities. It will also be helpful in using Neptune’s /profile or /explain APIs, which requires you to send the Gremlin that Gremlinq generates to understand query execution.

If a traversal that you want is difficult to express using the DSL, you can also fall back to lower-level Gremlin.NET traversals or other supported mechanisms in that specific part of your code base, while keeping the rest of the application in Gremlinq for type-safety and ergonomics.

Conclusion

Developing graph database applications with Gremlin can benefit from the type-safety and developer productivity features familiar from the .NET platform when using tools such as Gremlinq.

For .NET development with Amazon Neptune, Gremlinq offers an option that brings strong typing and a LINQ-like development style to Gremlin-based graph applications. This approach introduces an additional abstraction layer between your code and the underlying Gremlin traversals but offers compile-time type checking and familiar C# patterns while requiring you to work within the constraints of the DSL. You can use the tooling to inspect the generated traversals and replace specific queries with hand-tuned Gremlin should the need arise. The project templates streamline your initial setup, and the documentation provides deeper exploration of projections, aggregations, groupings, and more advanced query patterns.

Whether you’re building a recommendation engine, modeling complex organizational hierarchies, or implementing fraud detection logic, you can evaluate whether the type-safety and productivity benefits of working with strongly typed code align with your team’s priorities and development workflow.


About the authors

Stephen Mallette

Stephen Mallette

Stephen is a member of the Amazon Neptune team at AWS. He first started contributing to Apache TinkerPop, the home of the Gremlin graph query language, in 2009 and continues to share his experience there.

Daniel Weber

Daniel Weber

Daniel is a Senior Software Engineer at ExRam Innovations in Germany, where he created and maintains ExRam.Gremlinq. With over two decades of C# and .NET experience and a master’s degree in computer science from RWTH Aachen University, Daniel brings deep expertise in developer tooling for graph databases.