This repository demonstrates how I professionally will structure a GraphQL project, using a simple User–Post use case.
It focuses on clean architecture, Decorator patterns for middlewares like error handling (DRY Principle), resolver design, and efficient data fetching patterns.
- GraphQL API design
- User–Post relationship handling
- Caching layer (e.g. Custom DataLoader / In-memory cache using LRU )
- Prisma ORM integration
- Clean, scalable project structure
- Type-safe resolvers (TypeScript)
src/
├── graphql/
│ ├── resolvers/
│ ├── schema/
│ └── context/
├── lib/
│ ├── cachemap/
│ ├── constants/
│ ├── dataloaders/
│ ├── middlewares/
│ ├── prisma/
│ ├── types/
│ ├── utils/
│ └── validations/
└── index.ts
📂 graphql/ - Contains all graphql-related files e.g. Schema definition, Resolvers, Query, Mutations and Context Type for incoming requests.
resolvers/- Contains GraphQL resolver functions that implement the logic for queries and mutations. Example: fetching users, posts, or handling mutations like creating/updating posts.schema/- Holds your GraphQL schema definition files (.graphql) or typeDefs. Defines types, queries, mutations, and relationships in your API.context/- Provides the GraphQL context, that contains the dataloader clients, caching layers, and user authentication info. This context is accessible in all resolvers.
📂 lib/ – Core Library Utilities. Most of the backend codes are under here.
cachemap/- Contains caching mechanisms, such as LRU caches , to optimize repeated data fetching and prevent N+1 query issues (Reducing I/O Load). I wrote a custom LRU cache using doubly-linked list ( A well-known leetcode problem ).constants/- Holds constant values and messages used across the project, such as resolver success/error messages, configuration keys such as cache item limit, and status codes. Example use:POST_RESOLVER_MESSAGES.fetch_post_success.dataloaders/- Implements DataLoader utilities for batching and caching database requests efficiently. This helps reduce the number of queries when resolving fields likeUser.postse.g. User with many posts (1-to-many).middlewares/- Reusable middleware functions for GraphQL resolvers and dataloaders. For now it is Error handling usingdecorators.prisma/- Contains prisma client setup.types/- Contains both Custom Type-safe and Generated types and interfaces for: GraphQL context, Resolver arguments and responses and Prisma models. Purpose: maintain type safety and improve developer experience.utils/- Contains helpers for our resolvers (For now), thesehelperfunctions are :getRequestedFields()— Extracts requested fields from resolver infovalidateOrThrow()— Zod validationformatResponse()— Response formatterroupPostsByUser(): Post[]— AssignsUser/ author to eachPostmapPostsWithAuthors(): User[]- GroupPostsbyauthoridthen assign to eachUser
validations/- Contains the validation schema usingZod.
Code 1 (source) :
### src/graphql/resolvers
const userResolvers: Resolvers = {
Query: {
user: ComposeResolver(
async (_parent, args, context, info) => {
const user = await context.dataloaders.userLoader.load(args.id);
if (!user) return null;
const requested_fields = ResolverUtils.getRequestedFields(info);
if (requested_fields.has('posts')) {
const posts = await context.dataloaders.postLoader.getAllUserPosts(args.id);
return ResolverUtils.formatResponse({ user, posts }, USER_RESOLVER_MESSAGES.fetch_user_w_posts_success);
}
return ResolverUtils.formatResponse({ user }, USER_RESOLVER_MESSAGES.fetch_user_success);
},
[ExceptionHandler]
)
............
............
............
};For example Code 1, I pick the userResolvers resolver function definition.
- As shown, the user resolver is wrapped with
ComposeResolver, which allows us to separate concerns such as error handling and enables future enhancements like resolver-levelauthenticationandauthorization. The first argument represents the core resolver implementation (e.g., data fetching, caching), while the second argument is an array of middleware-like functions (such asExceptionHandler) that execute around the resolver. This middleware mechanism can be extended to includeauthentication,authorization, Grafanalogging, and other cross-cutting concerns, helping reduce code duplication and keeping the codebase clean and maintainable. - Next we have our
ResolverUtils:ResolverUtils.getRequestedFields(info)inspects the GraphQL query to determine which fields were requested by the client mitigating N + 1 problem when queryingpostsfor our users, reducing I/O latency from our backend service to database server as we request more data. ForResolverUtils.formatResponse(...), this is our helper function to format our graphql response. - For the dataloaders, we
userLoaderandpostLoaderthat contains theprismaclient andcachestoring and invalidation logics for ourprisma models/ data. - Lastly we have
USER_RESOLVER_MESSAGES, which is of one of the constants where we defined the messages for our graphql response.
Code 2 (source) :
### @lib/cachemap
class LRUNode<K, V> {
key: K;
val: V | null;
prev: LRUNode<K, V> | null = null;
next: LRUNode<K, V> | null = null;
constructor(key: K, val: V | null) {
this.key = key;
this.val = val;
}
}
export class LRUCache<K, V> {
public capacity: number;
public cache: Map<K, LRUNode<K, V>>;
public tail: LRUNode<K, V>;
public head: LRUNode<K, V>;
constructor(capacity: number) {
if (capacity <= 0) {
throw new Error('Capacity must be greater than 0');
}
this.capacity = capacity;
this.cache = new Map();
this.tail = new LRUNode<K, V>(null as K, null as V);
this.head = new LRUNode<K, V>(null as K, null as V);
this.tail.next = this.head;
this.head.prev = this.tail;
}
............
............
............
}For Code 2, this code is just the typical Data-structure doubly-linked list algorithm LRU style with Typescript sprinklings. This is the In-memory caching
algorithm I used for this project.
Code 3 (source) :
### @lib/dataloaders
export class PostDataLoader {
protected cachemap: PostCache;
protected repository: PrismaDelegates['post'];
protected prismaClient: PrismaClient;
constructor(prisma: PrismaClient, cache: PostCache) {
this.repository = prisma.post;
this.cachemap = cache;
this.prismaClient = prisma;
}
@HandleErrors()
async load(id: PostModelId): Promise<PostModel | null> {
if (this.cachemap.has(id)) return this.cachemap.get(id)!;
const queried_item = await this.repository.findUnique({
where: { id },
});
this.cachemap.set(id, queried_item);
return queried_item;
}
............
............
............
}export function HandleErrors(): MethodDecorator {
return function (_target: object, _propertyKey: string | symbol, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
try {
return await originalMethod.apply(this, args);
} catch (err: unknown) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
const errorMessage = PRISMA_ERRORS[err.code];
if (errorMessage) {
throw new Error(errorMessage);
}
}
throw err;
}
};
return descriptor;
};
}For Code 3 we have the exact custom Dataloader code which is directly inspired from the graphql/dataloader npm package. For this project I directly
created a simple class without the same complexity of from the original package where it uses extra properties like batchLoadFn and other method properties.
Furthermore, I hardly find it difficult to customize such package and ending up having unsafe type scenarios. Furthermore on this codeblocks, I define a
method decorator HandleErrors to handle prisma errors when we do query and mutations.
Code 4 (source) :
### @lib/validations
const BaseUserInputSchema = z.object({
name: z.string().min(2).max(150),
bio: z.string().max(150).nullable().optional(),
age: z.number().max(150).int().positive(),
}) satisfies z.ZodType<UserSafeCreateInput>;
export const CreateUserInputSchema = BaseUserInputSchema;
export const UpdateUserInputSchema = CreateUserInputSchema.partial()
.extend({
id: z.number().int().positive(),
})
.refine(
(data) => {
const { id, ...rest } = data;
return Object.keys(rest).length > 0;
},
{
message: VALIDATION_MESSAGES.provide_at_least_one_field,
}
) satisfies z.ZodType<UserSafeUpdateInput>;### @lib/types
export type PostSafeCreateInput = Prisma.PostUncheckedCreateInput;
export type PostSafeUpdateInput = Override<
Prisma.PostUncheckedUpdateInput, {authorId?: number;}
>;
export type UserSafeCreateInput = Prisma.UserCreateWithoutPostsInput;
export type UserSafeUpdateInput = Prisma.UserUpdateWithoutPostsInput;
export type PostModelId = PostModel['id'];
export type UserModelId = UserModel['id'];For this last example, I use Zod for validation, one of the most well-known alternatives to Yup.
I treat Prisma’s generated types as the single source of truth and use them as the basis for my Zod schemas.
This guarantees strong type safety for both create and update prisma operations, ensuring that runtime validation and compile-time types stay perfectly aligned.
Closing Statement : I built this repository as a TypeScript backend-focused project to showcase how I approach clean code, strong type safety, and backend design (Mainly GraphQL). The structure reflects my experience as a Software Engineer and patterns I’ve applied and encountered while working within collaborative engineering teams. I emphasize maintainability and developer experience throughout the codebase. Feel free to browse the whole code :D