Adding Other Data Sources or Services
EntityGraphQL lets you add fields that resolve (fetch) data from sources other than the core query context you created your schema with. This is powerful as it let's you create a single API that brings together multiple data sources into an object graph.
Resolve<TService>() Fields
To use other services in a field we use the Resolve<TService>() method on the field. EntityGraphQL uses the IServiceProvider you pass on execution to resolve the services.
Let's use a service in our Person type example.
// Where you create your schema
schema.UpdateType<Person>(personType => {
personType.AddField("age", "Person's age")
.Resolve<IAgeService>((person, srv) => srv.GetAge(person.Dob));
});
// Startup.cs
services.AddSingleton<IAgeService, AgeService>();
// AgeService.cs
public class AgeService
{
public int GetAge(DateTime dob)
{
return (now - dob).TotalYears;
}
}
Now when someone requests the age field on a person the result will be resolved by executing the service srv.GetAge(person.Dob). Of course you could calculate the age directly in the field's resolve expression without a service, this is just a demonstration on how to use other services.
Services With Non-Scalar Types
A service can return any type. If it is a complex type you will need to add it to the schema.
schema.Query().AddField("users", "Get list of users")
.Resolve<IUserService>(
// ctx is the core context we created the schema with. For this field we don't use it
(ctx, srv) => srv.GetUsers()
);
schema.AddType<User>("User", "User information")
.AddAllFields();
public class UserService : IUserService
{
public List<User> GetUsers() { ... }
}
public class User
{
public int Id { get; set; }
public string Email { get; set; }
}
Connecting Back to the Core Context
With the User example above you might want to add fields to the User type that brings in data back from the core context. This let's you create a rich object graph for querying.
When joining non-core context types back to the core context you need to use Resolve() again.
schema.UpdateType<User>(userType => {
userType.AddField("tasks", "List of projects assigned to the user")
.Resolve<DemoContext>(
(user, db) => db.Projects.Where(project => project.AssignedToId == user.Id)
);
});
Now we get query the user and their projects at the same time.
query {
users {
name
projects {
name
summary
}
}
}
Complex Service Types
If the type you want to define in the schema has data resolved from multiple different methods in your service you can create a service type class.
Let's say we want a root-level metrics field where each sub-field uses a service to load the value. If someone only queries 1 of the fields you don't want all of them to resolve/execute. To achieve this we can do the following.
Define a Metrics class that uses a service to provide the functionality.
public class Metrics
{
private IMetricService m;
public Metrics(IMetricService m)
{
this.m = m;
}
public int TotalWebhooks => m.TotalWebhooks();
public int TotalApiKeys => m.TotalApiKeys();
public int TotalUsers => m.TotalUsers();
}
No we can add the types & fields to GraphQL.
// add the type
var metricsType = adminSchema.AddType<Metrics>("Metrics", "Contains summary metrics")
.AddAllFields();
// add the root-level field
adminSchema.Query().AddField("metrics", "Return summary metrics")
.Resolve<IMetricService>(
(db, m) => new Metrics(m)
);
}
Now we can query
{
metrics {
totalWebhooks
}
}
To demonstrate that only the m.TotalWebhooks() method is called, here is what is produced as the .NET expression.
(MyDbContext db, IMetricService m) => {
var context = new Metric(m);
return new
{
totalWebhooks = context.TotalWebhooks,
}
};
You'll see none of the other fields are in the expression and therefore the methods will not execute.
Bulk data loading
You'll notice that a service field (that may return a complex object) selected within a list field will trigger the service to be called for each item in the list to resolve the service field. For example let's assume our user data comes from an external service
schema.UpdateType<Project>(type => {
type.AddField("createdBy", "Get the user details of user that created this project")
.Resolve<IUserService>((project, srv) => srv.GetUserById(project.CreatedById));
});
If we query all projects
{
projects {
name
id
createdBy {
name
}
}
}
EntityGraphQL will create an expression similar to this.
(queryContext, userService) => queryContext.Projects.Select(project => {
name = project.Name,
id = project.Id,
createdBy = new {
name = userService.GetUserById(project.CreatedById).Name
}
});
Note this is just to demonstrate concepts, EntityGraphQL will wrap the call to userService.GetUserById(project.Id) to avoid multiple calls if you select other fields on the createdBy field. However it will be called for each Project in the results. If there are 1,000 projects, GetUserById will be called 1,000 times with the ID for that project.
Depending on what GetUserById does this may not be an issue. You can also build your services to include a short-timed cache for results to speed things up. However, you may want to actually load all the user data for all projects at once. To do this you can use ResolveBulk.
schema.UpdateType<Project>(type =>
{
type.AddField("createdBy", "Get the user details of user that created this project")
// normal service to fetch the User object for creator of the Project type
.Resolve<UserService>((proj, users) => users.GetUserById(proj.CreatedById))
// Bulk service used to fetch many User objects
.ResolveBulk<UserService, int, User>(proj => proj.CreatedById, (ids, srv) => srv.GetAllUsers(ids));
});
Now the following queries will trigger one or the other service call
{
# ResolveBulk
projects {
# project fields
name
id
# service field - resolved with ResolveBulk expression for all Projects loaded
createdBy {
name
}
}
# Resolve
project(id: 78) {
# project fields
name
id
# service field - resolved with Resolve service expression for the single project loaded
createdBy {
name
}
}
}
Instead of calling users.GetUserById() for each project to resolve createdBy { name }, EntityGraphQL will build a list of keys using the proj => proj.CreatedById expression from the list of projects and then call the (ids, srv) => srv.GetAllUsers(ids) expression once for the whole list of projects in the results.
The bulk loader method signature needs to match the following
public IDictionary<TKey, TResult> MethodName(IEnumerable<TKey> data) {}
// Example of this above
public IDictionary<int, User> GetAllUsers(IEnumerable<int> data) {}
The IEnumerable<T> that is passed into you bulk data loader is not a unique list. Depending on where in the graph the field is selected it may end up with duplicate items. It is up to your implementation to handle that, if it is a simple int etc. you can just call .Distinct() on it. As it returns an IDictionary<,> you'll get a runtime duplicate key error if you do not handle it.
The method returns a IDictionary<> where the key is the same type as the key selector in ResolveBulk (proj => proj.CreatedById above) and the value is the return type of the field (User in the above example).
ResolveBulk signature is ResolveBulk<TService, TKey, TResult>(ctx => keySelector, (ids, service) => dataLoader) where TService is the service that will be injected and used to load the data. keySelector is an expression that is used to select the IDs/keys from each project object. dataLoader is an expression that will be passed a list of those selected IDs/keys and should return an IDictionary<TKey, TResult> that maps each result to a key.
It might help to conceptually see what this is doing in code.
// before we bulk load service data
var data = ctx.Projects.Select(p => new {
name = p.Name,
id = p.Id
p.CreatedById
});
// We now select the keys using the keySelector from ResolveBulk
var projectUserKeys = data.Projects.SelectMany(p => p.CreatedById);
// call service bulk loader
var projectUsers = userService.GetAllUsers(projectUserKeys);
// Now we can select out the query fields
var result = data.Select(p => new {
p.name,
p.id
user = new {
name = projectUsers[p.CreatedById].Name,
// ... any other fields
}
});
Async Bulk Data Loading
For scenarios where your bulk data loading operations are asynchronous (e.g., making HTTP calls to external APIs, async database operations), you can use ResolveBulkAsync. This works similarly to ResolveBulk but supports Task<T> return types and includes concurrency limiting.
schema.UpdateType<Project>(type =>
{
type.AddField("createdBy", "Get the user details of user that created this project")
// normal async service to fetch the User object for creator of the Project type
.ResolveAsync<UserService>((proj, users) => users.GetUserByIdAsync(proj.CreatedById))
// Async bulk service used to fetch many User objects
.ResolveBulkAsync<UserService, int, User>(proj => proj.CreatedById, (ids, srv) => srv.GetAllUsersAsync(ids));
});
The async bulk loader method signature needs to match the following:
public Task<IDictionary<TKey, TResult>> MethodName(IEnumerable<TKey> data) {}
// Example of this above
public async Task<IDictionary<int, User>> GetAllUsersAsync(IEnumerable<int> data)
{
// Make async calls to external API, database, etc.
var users = await externalApiClient.GetUsersAsync(data);
return users.ToDictionary(u => u.Id, u => u);
}
Concurrency Limiting
ResolveBulkAsync supports concurrency limiting to prevent overwhelming external services or hitting rate limits. You can specify the maximum number of concurrent bulk operations:
schema.UpdateType<Project>(type =>
{
type.AddField("createdBy", "Get the user details of user that created this project")
.ResolveAsync<UserService>((proj, users) => users.GetUserByIdAsync(proj.CreatedById))
// Limit to 5 concurrent bulk operations
.ResolveBulkAsync<UserService, int, User>(
proj => proj.CreatedById,
(ids, srv) => srv.GetAllUsersAsync(ids),
maxConcurrency: 5
);
});
Concurrency can also be configured at different levels:
- Field level: Using the
maxConcurrencyparameter as shown above - Query level: Set
ExecutionOptions.MaxConcurrencywhen executing the query - Service level: Configure in your dependency injection container
When multiple concurrency limits are specified, all will be applied.
The concurrency limiting applies to how many bulk loader operations can run simultaneously, not to individual items within a single bulk operation. This helps prevent overwhelming external services while still allowing efficient batching of requests.
Fetching only the fields that will be used
A resolver usually has to fetch whole objects, even when the caller asked for two of their columns. If the data comes from another database or another service, that over-fetching is the cost you were trying to avoid by batching in the first place.
Take an IFieldSelection parameter and the engine tells you what it will read off the objects you return:
schema.UpdateType<Project>(type =>
{
type.AddField("createdBy", "Get the user details of user that created this project")
.Resolve<UserService, IFieldSelection>((proj, users, selection) => users.GetUserById(proj.CreatedById, selection))
.ResolveBulk<UserService, IFieldSelection, int, User>(
proj => proj.CreatedById,
(ids, users, selection) => users.GetAllUsers(ids, selection)
);
});
public IDictionary<int, User> GetAllUsers(IEnumerable<int> ids, IFieldSelection selection)
{
// selection.Paths for { name, address { city } } is ["Name", "Address.City"]
return remoteApi.GetUsers(ids, fields: selection.Paths);
}
IFieldSelection is supplied by the engine, like CancellationToken and QueryRequestContext - it is not resolved from your service provider, and there is no separate Resolve... method for it. Use the two-service overloads to take it alongside your own service. It is only supplied to a field that has a selection set; a field returning a scalar has nothing selected on it and asking for one is an error.
Fields gives the same information as a tree, each entry carrying the SchemaField it came from so you can map back to your own members:
foreach (var field in selection.Fields)
{
Console.WriteLine(field.Name); // Address
Console.WriteLine(field.SchemaField?.Name); // address
foreach (var child in field.Fields)
Console.WriteLine(child.Name); // City
}
What you are told, and what you are not
This is deliberately not the caller's selection set. It is what the engine will read off the objects you return, which is the set you have to fetch for the response to be complete. The differences matter:
- A selected field that is itself resolved from a service is replaced by what its resolver reads. You cannot load it - the engine resolves it after you, from its own resolver - so you are told the member it needs instead. For
user { name manager { name } }wheremanageris another service field keyed onManagerId, you are asked forNameandManagerId, notManager.Name. ReturnManagerIdormanagerhas nothing to work from. - Nested objects the engine reads through are included as paths.
user { address { city } }givesAddress.City, and notAddress.Postcode. - The bulk key is not included. It is read off the parent object to build the list of ids, and your loader keys its own dictionary - nothing is read off what you return for it. Fetch it because your
ToDictionaryneeds it, not because it appears here. @skipand@includeare applied, so a skipped field is not asked for.- Fragments are expanded, so it makes no difference whether the caller wrote the fields inline or hoisted them into a fragment.
- Aliases collapse onto the field they select, and
__typenameis never included - nothing is read for it. - One load serves every place the field is selected, so you are given the union of what those places read. Selecting
createdBy { name }in one place andcreatedBy { email }in another is a single bulk call asking for both. Fetching only one place's fields would leave the other with nulls.
The set can therefore be larger than any one selection in the query, and shallower than it where it stops at a service field. Treat it as "the columns I must return", not as "what the caller asked for".
Building this walks the selection, so it is only done for a resolver that asked for it. Schemas that do not use IFieldSelection pay nothing.
Limitation using services with [GraphQLField] method fields
Because EntityGraphQL handles service fields by executing an expression without those fields and rewriting the expressions to work with the resulting type - see How EntityGraphQL handles services section for more details - an instance method that uses services on a type other than your root query type cannot be used. EntityGraphQL has nothing to rewrite the call against: the method needs the entity as its call target and the entity does not survive the first execution.
Such a field is still added to the schema - it works with ExecutionOptions.ExecuteServiceFieldsSeparately = false, and may never be queried - so this is reported when the field is queried rather than at schema build.
Methods that declare what they read from the context can be used - see Declaring what a method reads from the context below.
public class Movie
{
public int Id { get; set; }
// ...
[GraphQLField]
public uint[] AgesOfActorsAtRelease(IAgeCalculator calc)
{
List<uint> ages = calc.CalculateAges(Released, Actors);
return ages
}
}
public class AgeCalculator : IAgeCalculator
{
public List<uint> CalculateAges(DateTime released, IEnumerable<Actor>)
{
var ages = new List<uint>();
foreach (var actor in actors)
{
ages.Add((uint)((released - actor.Person.Dob).Days / 365));
}
return ages.ToArray();
}
}
With a query:
{
movies {
title
released
agesOfActorsAtRelease
}
}
In the above example AgesOfActorsAtRelease is a method on type Movie. When EntityGraphQL executes service fields it first builds and executes executes the following (which is safe for EF).
(DemoContext ctx) = > ctx.Movies.Select(m => new {
title = m.Title,
released = m.Released
}).ToList();
The resulting type is no longer Movie and we can no longer find the AgesOfActorsAtRelease method. Please use the API on Field as above:
schema.UpdateType<Movie>(type => {
type.AddField("agesOfActorsAtRelease", "All the actors ages")
.Resolve<IAgeCalculator>((movie, srv) => srv.CalculateAges(movie.Released, movie.Actors));
});
As (movie, srv) => srv.CalculateAges(movie.Released, movie.Actors) is an expression, EntityGraphQL can rewrite it to work with the first execution result. It also can visit the expression tree to know that Released and Actors needs to be selected in that first execution.
If you prefer keeping such field definitions grouped in classes rather than inline AddField() calls, a [GraphQLField] method can return that same expression - see Expression fields with AddFieldsFrom:
public class MovieExtraFields : IFieldsFor<Movie>
{
[GraphQLField("agesOfActorsAtRelease", "All the actors ages")]
public static Expression<Func<Movie, IAgeCalculator, uint[]>> AgesOfActorsAtRelease() =>
(movie, srv) => srv.CalculateAges(movie.Released, movie.Actors);
}
schema.Type<Movie>().AddFieldsFrom<MovieExtraFields>();
If you use ExecutionOptions.ExecuteServiceFieldsSeparately = false you will need to make sure all data is available / handle possible nulls.
Declaring what a method reads from the context
If you want a method body rather than a single expression - it loops, branches, or awaits - mark the method static and declare each thing it reads from the context with [GraphQLFromContext]. EntityGraphQL selects those members in the first execution and rewrites the call to read them from the result, exactly as it does for .Resolve() dependencies.
public class Movie
{
public int Id { get; set; }
public DateTime Released { get; set; }
public List<Actor> Actors { get; set; } = [];
[GraphQLField]
public static uint[] AgesOfActorsAtRelease(
IAgeCalculator calc,
[GraphQLFromContext(nameof(Released))] DateTime released,
[GraphQLFromContext(nameof(Actors))] List<Actor> actors
)
{
// a real body - not restricted to a single expression
var ages = new List<uint>();
foreach (var actor in actors)
ages.Add(calc.AgeAt(released, actor));
return ages.ToArray();
}
}
With the query { movies { name agesOfActorsAtRelease } } the first execution becomes
(DemoContext ctx) => ctx.Movies.Select(m => new {
name = m.Name,
egql__m_Released = m.Released,
egql__m_Actors = m.Actors
}).ToList();
Only the declared members are selected - Actors is loaded because the method asked for it, and nothing else on Movie is.
Notes:
- The member name is not checked against your GraphQL schema, only against the .NET type. Use
nameof()so a rename does not break it silently. A name that does not exist on the context type throws at schema build. - Omit the name (
[GraphQLFromContext] DateTime released) to bind to the member matching the parameter's own name, case-insensitively. - The parameter type must be assignable from the member's. Where a conversion is needed but safe -
inttoint?, or boxing toobject- it is added for you. - The member is read off the .NET type, so
[GraphQLIgnore]or field level authorization on that member do not apply - the same as an expression you pass to.Resolve(). - Every dependency has to be declared. A parameter of the context type itself (
AgesOfActorsAtRelease(Movie movie, IAgeCalculator calc)) is rejected at schema build when the method also uses services, because it would put the entity in the first execution: you would get its mapped members but not its navigation properties, and a body reading one would see an empty collection - or a populated one, if another field in the same query happened to load it. Declaring the members is the only form that cannot mislead you. - The method must be
static. An instance method on the entity is still added to the schema but fails when the field is queried, with a message pointing here - the attribute cannot help, the call target itself is the problem. Methods added withAddFieldsFrom<T>()may be instance methods, as the call target is yourIFieldsFor<T>class rather than the entity. [GraphQLFromContext]on a mutation or subscription throws at schema build - they have no field context to bind to.