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:
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:
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:
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:
The Gremlinq version expresses the same traversal using strongly typed C# constructs with compile‑time type information throughout the query:
V<Airport>()returns anIVertexGremlinQuery<Airport>- The
.Where()method accepts a lambda expression with IntelliSense forAirportproperties .Out<Route>()traverses edges of typeRoute.OfType<Airport>()narrows the result to airports- The result is of type
Airport[], an array ofAirport. 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:
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:
Creating a Neptune project
Create a new console project configured for Amazon Neptune:
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:
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:
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:
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’:
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:
The following listing breaks this query down step by step:
.V<Airport>()– Start with airport vertices.Where(airport => airport.Code == "SEA")– Filter on Seattle.Out<Route>()– Traverse outgoingRouteedges.OfType<Airport>()– Checks results are of typeAirport(they will be in any case given the schema, but the explicitOfTypeconfirms it and reestablishes type information).ToArrayAsync()– Execute and returnAirport[]
Finding incoming routes:
Swap .Out<Route>() for .In<Route>() to reverse the direction we’re walking:
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:
This query:
- Starts at SEA
- Traverses to the outgoing
Routeedges (not directly to vertices) - Filters routes where
Distance <= 1500 - 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:
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:
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:
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:
This also works for multiple ordering criteria:
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:
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:
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:
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:
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.
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.
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:
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:
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):
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.
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#.
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:
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:
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:
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.