Entity Framework has the concept of Navigation Properties:
A property defined on the principal and/or dependent entity that contains a reference(s) to the related entity(s).
In the context of GraphQL, Root Graph is the entry point to performing the initial EF query. Nested graphs then usually access navigation properties to return data, or perform a new EF query. New EF queries can be performed with AddQueryField and AddQueryConnectionField. Navigation properties queries are performed using AddNavigationField and AddNavigationConnectionField. For the above *ConnectionField refer to the GraphQL concept of pagination using Connections.
When performing a query there are several approaches to Loading Related Data
- Eager loading means that the related data is loaded from the database as part of the initial query.
- Explicit loading means that the related data is explicitly loaded from the database at a later time.
- Lazy loading means that the related data is transparently loaded from the database when the navigation property is accessed.
Ideally, all navigation properties would be eagerly loaded as part of the root query. However determining what navigation properties to eagerly is difficult in the context of GraphQL. The reason is, given the returned hierarchy of data is dynamically defined by the requesting client, the root query cannot know what properties to include. To work around this GraphQL.EntityFramework interrogates the incoming query to derive the includes. So for example take the following query
{
hero {
name
friends {
name
address {
town
}
}
}
}Would result in the following query being performed
context.Heros
.Include("Friends")
.Include("Friends.Address");The string for the include is taken from the field name when using AddNavigationField or AddNavigationConnectionField with the first character upper cased. This can be customized using a projection expression:
// Using projection to specify which navigation properties to include
AddNavigationConnectionField(
name: "employeesConnection",
projection: _ => _.Employees,
resolve: ctx => ctx.Projection);
// Multiple properties can be included using anonymous types
AddNavigationField(
name: "child1",
projection: _ => new { _.Child1, _.Child2 },
resolve: ctx => ctx.Projection.Child1);
// Nested navigation paths are also supported
AddNavigationField(
name: "level3Entity",
projection: _ => _.Level2Entity.Level3Entity,
resolve: ctx => ctx.Projection);The projection expression provides type-safety and automatically extracts the include names from the accessed properties.
GraphQL.EntityFramework automatically optimizes Entity Framework queries by using projections. When a GraphQL query is executed, only the fields explicitly requested in the query are loaded from the database, rather than loading entire entity objects.
When querying entities through GraphQL, the incoming query is analyzed and a projection expression is built that includes:
- Primary Keys - Always included (e.g.,
Id) - Foreign Keys - Always included automatically (e.g.,
ParentId,CategoryId) - Requested Scalar Fields - Fields explicitly requested in the GraphQL query
- Navigation Properties - With their own nested projections
For example, given this entity:
public class Order
{
// Primary key
public int Id { get; set; }
// Foreign key
public int CustomerId { get; set; }
// Navigation property
public Customer Customer { get; set; } = null!;
public string OrderNumber { get; set; } = null!;
public decimal TotalAmount { get; set; }
public string InternalNotes { get; set; } = null!;
}And this GraphQL query:
{
order(id: "1") {
id
orderNumber
}
}The library will generate an EF query that projects to:
static void ProjectionExample(MyDbContext context) =>
_ = context.Orders.Select(o => new Order
{
// Requested (and primary key)
Id = o.Id,
// Automatically included (foreign key)
CustomerId = o.CustomerId,
// Requested
OrderNumber = o.OrderNumber
});Note that TotalAmount and InternalNotes are not loaded from the database since they weren't requested.
The automatic inclusion of foreign keys is useful when writing custom field resolvers. Since foreign keys are always available in the projected entity, it is safe to use them without worrying about whether they were explicitly requested:
public class OrderGraph :
EfObjectGraphType<MyDbContext, Order>
{
public OrderGraph(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService) =>
// Custom field that uses the foreign key
Field<StringGraphType>("customerName")
.ResolveAsync(async context =>
{
var data = ResolveDbContext(context);
// CustomerId is available even though it wasn't in the GraphQL query
return await data.Customers
.Where(c => c.Id == context.Source.CustomerId)
.Select(c => c.Name)
.SingleAsync();
});
}Without automatic foreign key inclusion, context.Source.CustomerId would be 0 (or Guid.Empty for Guid keys) if customerId wasn't explicitly requested in the GraphQL query, causing the query to fail.
When using Field().Resolve() or Field().ResolveAsync() in graph types, navigation properties on context.Source may not be loaded if the projection system didn't include them. To safely access navigation properties in custom resolvers, use the projection-based extension methods:
public class ChildGraphType : EfObjectGraphType<IntegrationDbContext, ChildEntity>
{
public ChildGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
base(graphQlService) =>
Field<int>("ParentId")
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
projection: x => x.Parent,
resolve: ctx => ctx.Projection.Id);
}Available Extension Methods:
Resolve<TDbContext, TSource, TReturn, TProjection>()- Synchronous resolver with projectionResolveAsync<TDbContext, TSource, TReturn, TProjection>()- Async resolver with projectionResolveList<TDbContext, TSource, TReturn, TProjection>()- List resolver with projectionResolveListAsync<TDbContext, TSource, TReturn, TProjection>()- Async list resolver with projection
The projection-based extension methods ensure required data is loaded by:
- Storing projection metadata in field metadata
- Compiling the projection expression for runtime execution
- Applying the projection to
context.Sourcebefore calling the resolver - Providing the projected data via
ResolveProjectionContext<TDbContext, TProjection>
Problematic Pattern (Navigation Property May Be Null):
Field<int>("ParentId")
.Resolve(context => context.Source.Parent.Id); // Parent may be nullSafe Pattern (Projection Ensures Data Is Loaded):
Field<int>("ParentId")
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
projection: x => x.Parent,
resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loadedNote: A Roslyn analyzer (GQLEF002) warns at compile time when Field().Resolve() accesses properties other than primary keys and foreign keys. Only PK and FK properties are guaranteed to be loaded by the projection system - all other properties (including regular scalars like Name) require projection-based extension methods to ensure they are loaded.
When a field has its own custom resolver but still needs to declare data requirements for the parent query's projection, use WithProjection. Unlike the Resolve<..., TProjection> methods which both set metadata and wrap the resolver, WithProjection only sets projection metadata — ensuring the parent query loads the required columns without altering how the field resolves.
This is useful for computed or permission-check fields that read from context.Source but aren't standard navigation or scalar fields.
// The parent query's SELECT projection will include Status,
// even if the client doesn't explicitly request a "status" field.
Field<NonNullGraphType<StringGraphType>, string>("statusLabel")
.WithProjection(
(Expression<Func<OrderEntity, OrderStatus>>)(_ => _.Status))
.Resolve(context => context.Source.Status switch
{
OrderStatus.Active => "Active",
OrderStatus.Cancelled => "Cancelled",
_ => "Unknown"
});How it works:
WithProjectionstores the projection expression in the field's metadata (same key used byResolve<..., TProjection>)- When the parent entity query builds its SELECT projection, it analyzes all child field metadata — including this field
- Scalar properties referenced in the expression (e.g.,
Status) are added to the SELECT column list - Navigation properties referenced in the expression trigger the appropriate
Includecalls
When to use WithProjection vs Resolve<..., TProjection>:
| Scenario | Use |
|---|---|
| Field resolver needs projected data passed in | Resolve<..., TProjection> |
| Field has its own resolver but parent must load certain columns | WithProjection |
| Permission/authorization fields that check entity state | WithProjection |
| Fields resolved via reflection or external logic | WithProjection |
Projections are bypassed and the full entity is loaded in these cases:
- Read-only computed properties - When any property has no setter or is expression-bodied (at any level, including nested navigations)
- Abstract entity types - When the root entity type or any navigation property type is abstract
In these cases, the query falls back to loading the complete entity with all its properties.
Projections provide significant performance improvements:
- Reduced database load - Only requested columns are retrieved from the database
- Less data transferred - Smaller result sets from database to application
- Lower memory usage - Smaller objects in memory
For queries that request only a few fields from entities with many properties, the performance improvement can be substantial.
Queries in GraphQL.net are defined using the Fields API. Fields can be mapped to Entity Framework by using IEfGraphQLService. IEfGraphQLService can be used in either a root query or a nested query via dependency injection. Alternatively convenience methods are exposed on the types EfObjectGraphType or EfObjectGraphType<TSource> for root or nested graphs respectively. The below samples all use the base type approach as it results in slightly less code.
public class Query :
QueryGraphType<MyDbContext>
{
public Query(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService)
{
AddSingleField(
resolve: _ => _.DbContext.Companies,
name: "company");
AddQueryField(
name: "companies",
resolve: _ => _.DbContext.Companies);
}
}AddQueryField will result in all matching being found and returned.
AddSingleField will result in a single matching being found and returned. This approach uses IQueryable<T>.SingleOrDefaultAsync as such, if no records are found a null will be returned, and if multiple records match then an exception will be thrown.
public class CompanyGraph :
EfObjectGraphType<MyDbContext,Company>
{
public CompanyGraph(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService)
{
AddNavigationListField(
name: "employees",
projection: _ => _.Employees,
resolve: _ => _.Projection);
AddNavigationConnectionField(
name: "employeesConnection",
projection: _ => _.Employees,
resolve: _ => _.Projection);
AutoMap();
}
}Creating a page-able field is supported through GraphQL Connections by calling IEfGraphQLService.AddNavigationConnectionField (for an EF navigation property), or IEfGraphQLService.AddQueryConnectionField (for an IQueryable). Alternatively convenience methods are exposed on the types EfObjectGraphType or EfObjectGraphType<TSource> for root or nested graphs respectively.
public class Query :
QueryGraphType<MyDbContext>
{
public Query(IEfGraphQLService<MyDbContext> graphQlService)
:
base(graphQlService) =>
AddQueryConnectionField<Company>(
name: "companies",
resolve: _ => _.DbContext.Companies.OrderBy(_ => _.Name));
}{
companies(first: 2, after: "1") {
totalCount
edges {
node {
id
content
employees {
id
content
}
}
cursor
}
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
}
}{
"data": {
"companies": {
"totalCount": 4,
"edges": [
{
"node": {
"id": "1",
"content": "Company1",
"employees": [
{
"id": "2",
"content": "Employee1"
},
{
"id": "3",
"content": "Employee2"
}
]
},
"cursor": "1"
},
{
"node": {
"id": "4",
"content": "Company3",
"employees": []
},
"cursor": "2"
}
],
"pageInfo": {
"startCursor": "1",
"endCursor": "2",
"hasPreviousPage": true,
"hasNextPage": true
}
}
}
}public class CompanyGraph :
EfObjectGraphType<MyDbContext, Company>
{
public CompanyGraph(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService) =>
AddNavigationConnectionField(
name: "employees",
projection: _ => _.Employees,
resolve: _ => _.Projection);
}public class DayOfTheWeekGraph : EnumerationGraphType<DayOfTheWeek>
{
}public class ExampleGraph : ObjectGraphType<Example>
{
public ExampleGraph()
{
Field(x => x.DayOfTheWeek, type: typeof(DayOfTheWeekGraph));
}
}Mapper.AutoMap can be used to remove repetitive code by mapping all properties of a type.
For example for this graph:
public class EmployeeGraph :
EfObjectGraphType<SampleDbContext, Employee>
{
public EmployeeGraph(IEfGraphQLService<SampleDbContext> graphQlService) :
base(graphQlService)
{
AddNavigationField(
name: "company",
resolve: context => context.Source.Company);
Field(employee => employee.Age);
Field(employee => employee.Content);
Field(employee => employee.CompanyId);
Field(employee => employee.Id);
}
}The equivalent graph using AutoMap is:
public class EmployeeGraph :
EfObjectGraphType<SampleDbContext, Employee>
{
public EmployeeGraph(IEfGraphQLService<SampleDbContext> graphQlService) :
base(graphQlService)
{
AutoMap();
}
}The underlying behavior of AutoMap is:
- Calls
IEfGraphQLService{TDbContext}.AddNavigationField{TSource,TReturn}for all non-list EF navigation properties. - Calls
IEfGraphQLService{TDbContext}.AddNavigationListField{TSource,TReturn}for all EF navigation properties. - Calls
ComplexGraphType{TSourceType}.AddFieldfor all other properties
An optional list of exclusions can be passed to exclude a subset of properties from mapping.
Mapper.AddIgnoredType can be used to exclude properties (of a certain type) from mapping.
In some cases, it may be necessary to use Field instead of AddQueryField/AddSingleField/etc but still would like to use apply the where argument. This can be useful when the returned Graph type is not for an entity (for example, aggregate results). To support this:
- Add the
WhereExpressionGraphargument - Apply the
whereargument expression usingExpressionBuilder<T>.BuildPredicate(whereExpression)
Field<ListGraphType<EmployeeSummaryGraphType>>("employeeSummary")
.Argument<ListGraphType<WhereExpressionGraph>>("where")
.Resolve(context =>
{
var dbContext = ResolveDbContext(context);
IQueryable<Employee> query = dbContext.Employees;
if (context.HasArgument("where"))
{
var wheres = context.GetArgument<List<WhereExpression>>("where");
var predicate = ExpressionBuilder<Employee>.BuildPredicate(wheres);
query = query.Where(predicate);
}
return query
.GroupBy(_ => _.CompanyId)
.Select(_ => new EmployeeSummary
{
CompanyId = _.Key,
AverageAge = _.Average(_ => _.Age),
});
});Sometimes it is necessary to access the current DbContext from withing the base QueryGraphType.Field method. in this case the custom ResolveEfFieldContext is not available. In this scenario QueryGraphType.ResolveDbContext can be used to resolve the current DbContext.
public class Query :
QueryGraphType<MyDbContext>
{
public Query(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService) =>
Field<ListGraphType<CompanyGraph>>("oldCompanies")
.Resolve(context =>
{
// uses the base QueryGraphType to resolve the db context
var data = ResolveDbContext(context);
return data.Companies.Where(_ => _.Age > 10);
});
}ArgumentProcessor (via the method ApplyGraphQlArguments) is responsible for extracting the various parts of the GraphQL query argument and applying them to an IQueryable<T>. So, for example, each where argument is mapped to a IQueryable.Where and each skip argument is mapped to a IQueryable.Where.
The arguments are parsed and mapped each time a query is executer.
ArgumentProcessor is generally considered an internal API and not for public use. However there are some advanced scenarios, for example when building subscriptions, that ArgumentProcessor is useful.