How lazy should a GraphQL resolver be?
For some context, here's a birds-eye of my architecture: GraphQL -> Resolvers -> |Domain Boundary| -> Services -> Loaders -> Data Sources (Postgres/Redis/Elasticsearch)
Past the domain boundary, there are no GraphQL specific constructs. Services represent the various dimensions of the domain, and resolvers simply process SomeQueryInput, delegate to the proper services, and then construct a proper SomeQueryResult with the operation results. All business rules, including authorization, live in the domain. Loaders provide access to domain objects with abstractions over data sources, sometimes using the DataLoader pattern and sometimes not.
Let me illustrate my question with a scenario: Let's say there's a User that has-a Project, and a Project has-many Documents. A project also has-many Users, and some users might not be allowed to see all Documents.
Let's construct a schema, and a query to retrieve all documents that the current user can see.
type Query {
project(id:ID!): Project
}
type Project {
id: ID!
documents: [Document!]!
}
type Document {
id: ID!
content: String!
}
{
project(id: "cool-beans") {
documents {
id
content
}
}
}
Assume the user state is processed outside of the GraphQL context and injected into the context.
And some corresponding infrastructure code:
const QueryResolver = {
project: (parent, args, ctx) => {
return projectService.findById({ id: args.id, viewer: ctx.user });
},
}
const ProjectResolver = {
documents: (project, args, ctx) => {
return documentService.findDocumentsByProjectId({ projectId: project.id, viewer: ctx.user })
}
}
const DocumentResolver = {
content: (parent, args, ctx) => {
let document = await documentLoader.load(parent.id);
return document.content;
}
}
const documentService => {
findDocumentsByProjectId: async ({ projectId, viewer }) {
/* return a list of document ids that the viewer is eligible to view */
return getThatData(`SELECT id FROM Documents where projectId = $1 AND userCanViewEtc()`)
}
}
So the query execution would go: Resolve the project, get the list of documents the viewer is eligble to view, resolve the documents, and resolve their content. You can imagine the DocumentLoader being ultra-generic and unconcerned with business rules: Its sole job being to get an object of an ID as fast as possible.
select * from Documents where id in $1
My question revolves around documentService.findDocumentsByProjectId. There seems to be multiple approaches here: The service, as it is now, has some GraphQL knowledge baked into it: It returns "stubs" of the required objects, knowing that they will be resolved into proper objects. This strengthens the GraphQL domain, but weakens the service domain. If another service called this service, they'd get a useless stub.
Why not just have findDocumentsByProjectId do the following:
SELECT id, name, content FROM "Documents" JOIN permisssions, etc etc
Now the Service is more powerful and returns entire business objects, but the GraphQL domain has become more brittle: You can imagine more complex scenarios where the GraphQL schema is queried in a way the services don't expect, you end up with broken queries and missing data. You can also now just... erase the resolvers you wrote, as most servers will trivially resolve these already hydrated objects. You've taken a step back towards a REST-endpoint approach.
Additionally, the second method can leverage data source indexes intended for specific purposes, whereas the DataLoader uses a more brute force WHERE IN kind of approach.
How do you balance these concerns? I understand this is probably a big question, but it's something I've been thinking about a lot. Is the Domain Model missing concepts that could be useful here? Should the DataLoader queries be more specific than just using universal IDs? I struggle to find an elegant balance.
Right now, my services have both: findDocumentStubs, and findDocuments. The first is used by resolvers, the second used by other internal services since they can't rely on GraphQL resolution, but this doesn't feel quite right either. Even with DataLoader batching and caching, it still feels like someone is doing unecessary work.