Skip to content

Repository files navigation

GraphQL Backend Service - User-Post with In-memory Caching Project

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.


🚀 Features

  • 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)

🧱 Project Structure

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 like User.posts e.g. User with many posts (1-to-many).
  • middlewares/ - Reusable middleware functions for GraphQL resolvers and dataloaders. For now it is Error handling using decorators.
  • 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), these helper functions are :
    • getRequestedFields() — Extracts requested fields from resolver info
    • validateOrThrow() — Zod validation
    • formatResponse() — Response formatter
    • roupPostsByUser(): Post[] — Assigns User / author to each Post
    • mapPostsWithAuthors(): User[] - Group Posts by authorid then assign to each User
  • validations/ - Contains the validation schema using Zod.

🧑‍💻 Code Examples :

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-level authentication and authorization. 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 as ExceptionHandler) that execute around the resolver. This middleware mechanism can be extended to include authentication, authorization, Grafana logging, 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 querying posts for our users, reducing I/O latency from our backend service to database server as we request more data. For ResolverUtils.formatResponse(...), this is our helper function to format our graphql response.
  • For the dataloaders, we userLoader and postLoader that contains the prisma client and cache storing and invalidation logics for our prisma 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

About

This repository demonstrates how I structure a GraphQL project professionally with caching, using a typical User–Post use case.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages