When tRPC is not enough
trpcopenapiresttypescriptapi-design 5 min read

When tRPC is not enough

tRPC is excellent for first-party TypeScript clients, but Java, C#, mobile, partners, and public integrations need an explicit contract. That usually means REST plus OpenAPI.

tRPC is a strong default when the frontend and backend are both TypeScript and compile against the same router type.

That is the happy path.

But the moment another ecosystem enters the system, the contract changes shape. A Java service cannot import your AppRouter. A C# integration cannot rely on TypeScript inference. A native mobile app may ship on a different release cycle. A partner API consumer may need stable URLs, versioning rules, and generated clients in their own language.

At that point, tRPC is no longer the whole answer.

The mistake is not using tRPC. The mistake is pretending its TypeScript contract is also an external API contract.

The rule I use is simple:

tRPC for first-party TypeScript clients. REST and OpenAPI for external consumers.

Both can call the same use cases. They are just different driving adapters.

The boundary where tRPC stops

tRPC gives excellent guarantees inside a TypeScript boundary:

  • the client knows procedure inputs,
  • the client knows response shapes,
  • property renames fail at compile time,
  • no generated SDK has to be refreshed manually.

Those guarantees are real. They are also scoped.

They stop when the consumer cannot compile against the router type.

That includes:

  • Java and C# backend services,
  • native iOS and Android apps,
  • partner integrations,
  • public APIs,
  • automation scripts owned by another team,
  • long-lived clients that cannot be upgraded with the backend.

Those consumers need something language-neutral. In most product APIs, that means HTTP resources, status codes, JSON schemas, examples, and an OpenAPI document.

Keep the use case single

The important architectural rule is not “always use tRPC” or “always use REST”.

The rule is:

one use case, multiple driving adapters

The application layer should not know which adapter called it.

export type CreateOrderCommand = {
  customerId: string;
  userId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
};

export type CreateOrder = (
  command: CreateOrderCommand,
) => Promise<Result<OrderId, CreateOrderError>>;

A tRPC procedure can call this use case. A REST controller can call the same use case. A queue consumer can call it too.

The contract differs at the edge. The business behavior does not.

Option 1: generate OpenAPI from tRPC

If tRPC is already the source of truth for your first-party TypeScript client, you may use an existing solution to transform selected tRPC procedures into an OpenAPI document.

In a current tRPC v11 stack, the official @trpc/openapi package is one option to evaluate. It generates an OpenAPI 3.1 document from a tRPC router. I would treat it as infrastructure tooling, not as part of the domain, especially while the package is still marked alpha.

For generated OpenAPI, I make inputs and outputs explicit:

const CreateOrderInputSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity: z.number().int().positive(),
    }),
  ),
});

const CreateOrderOutputSchema = z.object({
  id: z.string().uuid(),
});

Then the procedure stays thin:

export const orderRouter = router({
  create: protectedProcedure
    .input(CreateOrderInputSchema)
    .output(CreateOrderOutputSchema)
    .mutation(async ({ input, ctx }) => {
      const result = await createOrder({
        ...input,
        userId: ctx.user.id,
      });

      if (!result.ok) {
        throw presentCreateOrderError(result.error);
      }

      return { id: result.value };
    }),
});

The OpenAPI generation step stays outside the application:

// scripts/generate-openapi.ts

import { writeFile } from "node:fs/promises";
import { generateOpenAPIDocument } from "@trpc/openapi";

const document = generateOpenAPIDocument("./src/infrastructure/trpc/routers/index.ts", {
  exportName: "AppRouter",
  title: "Fleet API",
  version: "1.0.0",
});

await writeFile("openapi.json", JSON.stringify(document, null, 2));

This is useful when the REST surface is secondary: internal tools, automation, or a small set of consumers that mostly need documentation and client generation.

Option 2: write a dedicated REST adapter

Generated OpenAPI is not the same thing as a designed public API.

When external consumers are central to the product, I prefer a dedicated REST adapter:

app.post("/v1/orders", async (req, res) => {
  const parsed = CreateOrderInputSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      code: "invalid_request",
      issues: parsed.error.issues,
    });
  }

  const result = await createOrder({
    ...parsed.data,
    userId: req.user.id,
  });

  if (!result.ok) {
    const error = presentCreateOrderRestError(result.error);
    return res.status(error.status).json(error.body);
  }

  return res.status(201).json({ id: result.value });
});

This is still Clean Architecture. The REST route is a driving adapter. It validates input, calls the use case, and presents the result.

The difference is product control.

With a dedicated adapter, you can design:

  • stable /v1 and /v2 URLs,
  • partner-friendly error codes,
  • examples for Java and C# clients,
  • deprecation headers,
  • pagination conventions,
  • authentication documentation,
  • backward-compatible response evolution.

That work is not duplication when the API is a product surface.

What I check before choosing

I ask five questions:

  1. Are the primary consumers TypeScript clients in the same release process?
  2. Will Java, C#, native mobile, or partners consume this API?
  3. Do we need stable URLs and long deprecation windows?
  4. Is OpenAPI documentation a byproduct or the source of truth?
  5. Would a breaking response change need approval outside the TypeScript team?

If the answer to the first question is yes and the rest are no, tRPC is probably enough.

If external consumers matter, I add REST/OpenAPI deliberately.

The decision rule

Do not make tRPC carry responsibilities it was not designed for.

Use it where it shines: first-party TypeScript clients, fast feedback, no SDK generation, and a thin driving adapter over clean use cases.

When the boundary crosses language, team, release, or company lines, make the contract explicit. Generate OpenAPI from tRPC when that is enough. Write a dedicated REST adapter when the API itself is a product.

The use case stays the same either way.

That is the architecture I want: one application core, multiple honest adapters, and no confusion between TypeScript inference and an external contract.

Mohammed Hamdoune is a Senior Software Architect specializing in TypeScript, DDD, and Clean Architecture. He writes about real-world engineering decisions at sidlynx.me and founded LRJI, a software architecture consulting firm at lrji.co.