From GraphQL Schemas to Custom RPC: Why We Built Our Own Solution
← Back
16.3.26

From GraphQL Schemas to Custom RPC: Why We Built Our Own Solution

How we ditched GraphQL friction for a custom TS-based RPC that eliminates boilerplate and boosts developer velocity on our Optimus platform.

Ido Elyakim
ByIdo Elyakim
Full-Stack Engineer

Background

Our internal admin platform, Optimus, is a React frontend backed by a Node.js/TypeScript backend. For years, GraphQL served as the communication layer between the two. Over time, however, the friction introduced by GraphQL's schema-driven approach became a real drag on developer velocity - especially for a codebase where a single team owns both sides of the stack.
‍
This post covers why we moved away from GraphQL, the alternatives we evaluated, and how we ended up building a custom RPC layer that generates fully typed frontend hooks directly from backend function signatures.

The Problem with GraphQL (For Us)

GraphQL is a powerful technology, and this isn't a critique of GraphQL in general. But in our specific context — an internal tool where one team controls both frontend and backend - it introduced overhead that outweighed its benefits.
‍
Too many files per feature
Adding a single endpoint required touching roughly 15 files scattered across the project:

  • Schema definitions - input types, output types, and enums in .gql files
  • Converters -mapping between backend TypeScript objects and GraphQL-generated types
  • Fragments - frontend GraphQL fragment definitions
  • Resolvers - backend logic wired to the schema

‍
For what was fundamentally "call a function and return data," the ceremony was disproportionate.
GraphQL-specific limitations
We hit recurring friction with GraphQL:

  • Poor code locality - our types were fragmented across schema files, resolver files, converter files, and frontend fragments spread across different directories. Understanding a single feature required navigating a maze of scattered files.
  • Repetitive type definitions - the GraphQL required a schema in order to work so we had to define a schema that is a replica of the backend instead of reuse what we had, The same structure was effectively written four times. (we could use annotation and generate from TypeScript to reduce some of it but it would require a lot of annotations and practically “contaminate“ our models)
  • Enum handling that didn't map cleanly to our TypeScript enums
  • Learning curve - in order for a developer to start work with GraphQL, he needs to understand the technology, how it works and it's advantages. This affected the learning curve because it was much harder to get used to it compared to native Typescript.

Evaluating Alternatives

Given the mounting complaints, we set out to find a better approach. Our core requirements were:

  1. No repetition - define types once, use them everywhere
  2. Easy to use - minimal learning curve for developers
  3. Separation —-keep backend concerns (Redis, DB, etc.) out of the frontend
  4. Keep current abilities - preserve the abilities of runtime validation, permissions mechanism and frontend requests caching.

We evaluated several options:

Option Assessment
tRPC Strong TypeScript integration, but still requires schema/validation definitions. Didn't meet our "no extra schema" requirement.
TypedGraphQL Stays in the GraphQL ecosystem — doesn't solve the fundamental file-sprawl and duplication problems.
Manual custom RPC Full control. No schema layer. Types derived directly from TypeScript code.
Other RPC libraries All of them required some form of schema or extra definitions.


The deciding factor was our requirement for a schema-less solution. We didn't want developers writing any extra type definitions, validation schemas, or configuration beyond the backend function itself. Most off-the-shelf solutions still required some form of schema or validation layer to generate frontend types.
Since no existing tool matched our needs, we built our own.

How the Custom RPC Works

The entire system hinges on a single convention: backend functions annotated with @OptimusResolver become API endpoints automatically.

Writing an endpoint
A developer writes a standard TypeScript function in a file with the Resolvers postfix:

1 @OptimusResolver({
2   resolverType: ResolverType.Mutation,
3   permission: Permission.Admin
4 })
5 export async function stageLiveopGroup(
6   configId: string,
7   params: LiveopGroupParams
8 ): Promise {
9   return await liveopService.stageGroups(configId, params);
10 }

That's the entire contract. The function's parameter types become the input. The return type becomes the output. The decorator specifies whether it's a query or mutation and what permissions are required.
No schema files. No converters. No fragments.

The codegen pipeline

From that single function, a four-step codegen process generates everything needed:

  1. JSON Schema generation
    All @OptimusResolver functions are scanned. Their parameter types and return types are extracted and compiled into a JSON Schema. Function parameters are merged into a single input object (named {functionName}Input ).
  2. OpenAPI specification
    The JSON Schema types are wrapped in a full OpenAPI spec. Each annotated function becomes an endpoint (e.g., /rpc/stageLiveopGroups ) with its input, output, and HTTP method defined.
  3. Orval hook generation
    The OpenAPI spec is fed into , which generates fully typed React Query hooks. The resolverType (query vs. mutation) determines whether the generated hook uses useQuery (with caching) or useMutation (with callbacks).
  4. Resolver collection
    A generated imports file collects all resolver functions. At server startup, these imports trigger the @OptimusResolver decorator logic, which registers each function into a central map. The createAutoRoutes function then iterates this map and creates the corresponding Express endpoints.

The entire pipeline runs as part of the existing codegen stage and completes in seconds - compared to the minute-plus cycle of building, starting the server, and running GraphQL codegen.

Using the generated hooks
On the frontend, using an endpoint looks like this:

1 const { mutate: stageLiveop } = useStageLiveopGroup();
2
3 stageLiveop({
4   configId: "abc-123",
5   params: { name: "Holiday Event", segments: [...] }
6 });

Full type safety. Autocomplete for every parameter. Compile-time errors for type mismatches. No manual type wiring required.

What We Gained

Fewer files, faster development
Adding a new endpoint now means editing one file instead of many files (we even had cases of fifteen files...). The cognitive overhead of navigating scattered schema, converter, and fragment files is gone.
‍
Types defined once
Backend TypeScript types are the single source of truth. No more manual translation between TypeScript → GraphQL → TypeScript.
‍
No learning curve
Developers only need to know TypeScript. There are no GraphQL-specific concepts to learn - no DataLoaders, no resolver types, no fragment composition.
‍
Faster feedback loop
Codegen runs in seconds as part of the existing build pipeline. No need to start a local server or run a separate frontend codegen step.
‍
Caching and permissions preserved
React Query handles caching through the query/mutation distinction. Permissions are declared in the decorator and enforced at the route level, just as they were with GraphQL.
‍
Runtime validation
Because we already had a generated OpenApi schema we were capable of adding a middleware for runtime validating the request and the response by the schema.

Trade-offs

This approach is not without trade-offs, and we want to be transparent about them:

  • Single-client assumption - Our RPC layer works well because one team owns both frontend and backend. For systems with multiple clients or public APIs, GraphQL's flexible querying is genuinely valuable.
  • No Lazy fetching and enrichers (important if you use microservices) - GraphQL's batching and lazy resolution patterns don't exist in this model. Where needed, data fetching must be handled explicitly in the resolver function.
  • Custom tooling maintenance - This is our code to maintain. We've documented it thoroughly and the pipeline is straightforward, but it's still a custom solution that the team needs to understand.

Conclusion

GraphQL wasn't wrong for us when we adopted it. But as our codebase grew and our team's needs evolved, the schema-driven approach created friction that a schema-less solution could eliminate.
When we couldn't find an off-the-shelf tool that matched our specific requirements - no schema, types derived purely from TypeScript function signatures - we built our own. The result is a custom RPC layer that's simpler, faster, and easier to maintain than what it replaced.
The key lesson: when you own both ends of the wire and the existing tooling creates more overhead than value, don't be afraid to question it - even if it's a widely-adopted industry standard.

‍