tRPC as a driving adapter
trpcclean-architecturehexagonal-architecturetypescriptapi-design 10 min read

tRPC as a driving adapter

tRPC can be a clean driving adapter when the domain and use cases stay plain TypeScript. The trick is to keep procedures thin: validate at the boundary, call the use case, and translate the result.

In the previous article, I explained why I stopped putting NestJS at the center of my DDD architecture.

The replacement was not another framework.

It was a boundary:

Plain TypeScript for domain and application code. Frameworks only at the edges.

That works well until the moment the outside world needs to call your use case. At some point, a browser, queue consumer, CLI command, or cron job needs to enter the system. That is where many clean architectures start to erode.

You have a pure use case. It is testable without HTTP. It takes a command and returns a Result. It does not know Express, NestJS, Fastify, React, or any transport detail.

Then you need to expose it.

The default move is familiar: write a route, DTOs, validation, a contract document, and a generated client, then keep them synchronized.

When both sides are TypeScript, it is often more ceremony than architecture.

Not as a replacement for the application layer or as a place to put business logic. As a driving adapter.

What a driving adapter does

In hexagonal architecture, adapters sit around the application core. They are not the core itself.

There are two sides:

  • Driving adapters call the application: HTTP routes, tRPC procedures, message consumers, CLI commands, cron jobs.
  • Driven adapters are called by the application: repositories, event buses, payment clients, mailers, external APIs.

A driving adapter has a narrow job:

  1. Validate input at the boundary.
  2. Translate the outside request into an application command.
  3. Call the use case.
  4. Translate the result back to the caller.

That is it.

No business rule. No persistence decision. No domain orchestration. No “just one small check” that actually belongs inside the use case.

The use case should be just TypeScript: it takes a command, uses ports, and returns a Result. It does not know how it was called. HTTP, tRPC, a job runner, and a test can all call the same function.

The adapter’s job is to protect that property.

The classic REST adapter is not the problem

There is nothing inherently wrong with a REST controller.

Validate the body, map it to a command, call the use case, map the result back to HTTP. That is a valid driving adapter. The friction starts at scale.

You now need to keep several things aligned:

  • The runtime validator.
  • The TypeScript input type.
  • The response type.
  • The external API contract.
  • The generated client.
  • The frontend call sites.
  • The error contract.

Most teams solve this with discipline, code generation, or contract tests. Those are legitimate tools. But in a full-stack TypeScript system, I prefer to remove some of the duplication instead of policing it forever.

tRPC maps well to the adapter shape

tRPC procedures are a good fit because they already look like a driving adapter:

  • .input(...) validates the request boundary.
  • The procedure body calls application code.
  • The return value becomes the typed response.
  • TRPCError translates failures into transport errors.
  • The client receives the inferred contract without a generated SDK.

Here is the same createOrder use case exposed through tRPC:

// src/infrastructure/trpc/routers/order.router.ts

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createOrder } from "../../../application/orders/create-order";
import { router, protectedProcedure } from "../trpc";

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(),
});

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 };
    }),
});

function presentCreateOrderError(error: CreateOrderError): TRPCError {
  switch (error.kind) {
    case "customer_not_found":
      return new TRPCError({
        code: "NOT_FOUND",
        message: "Customer not found.",
      });

    case "product_unavailable":
      return new TRPCError({
        code: "CONFLICT",
        message: "One or more products are unavailable.",
      });

    case "invalid_order":
      return new TRPCError({
        code: "BAD_REQUEST",
        message: error.message,
      });
  }
}

The important part is not the syntax.

The important part is the direction of dependency:

tRPC procedure
  -> input validation
  -> application command
  -> use case
  -> Result
  -> transport response

The use case does not import tRPC. It does not receive ctx. It does not throw TRPCError. It returns an application result. The tRPC procedure translates that result for this transport.

That is exactly what a driving adapter should do.

Keep the procedure thin

The most common mistake is treating tRPC as the application layer.

This is the line I do not cross:

export const orderRouter = router({
  create: protectedProcedure.input(CreateOrderInputSchema).mutation(async ({ input, ctx }) => {
    if (ctx.user.plan === "free" && input.items.length > 5) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: "Free users cannot create orders with more than five items.",
      });
    }

    // more checks...
    // persistence...
    // events...
    // response mapping...
  }),
});

That looks convenient. It is also how the adapter becomes the use case.

The rule I use is simple: if the rule would still exist when called from a queue consumer or CLI command, it belongs in the application or domain layer.

The adapter may answer transport questions:

  • Is the caller authenticated?
  • Does the request body match the expected shape?
  • How do I map this error to a tRPC error code?
  • Which user or tenant from the request context becomes part of the command?

The adapter should not answer business questions:

  • Is this order allowed?
  • Can this customer buy this product?
  • Should this event be published?
  • Which aggregate owns this invariant?

That split keeps tRPC useful without making the core depend on it.

Context and middleware replace framework guards

In a NestJS application, authentication often starts with guards and decorators. That is productive, but it also creates a strong gravitational pull toward framework-shaped application code.

With tRPC, I keep authentication in infrastructure and expose a typed context to procedures:

// src/infrastructure/trpc/context.ts

import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";

export async function createContext({ req }: CreateExpressContextOptions) {
  const user = await authenticate(req.headers.authorization);

  return {
    user,
    requestId: req.headers["x-request-id"] ?? crypto.randomUUID(),
  };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

Then I define base procedures:

// src/infrastructure/trpc/trpc.ts

import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "./context";

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;

const requireUser = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }

  return next({
    ctx: {
      user: ctx.user,
      requestId: ctx.requestId,
    },
  });
});

export const protectedProcedure = t.procedure.use(requireUser);

This is the same architectural role as a guard: reject unauthenticated calls before the use case runs. But it stays inside the driving adapter. The use case still receives a command, not a request object, decorator, or framework context.

The client gets the contract without a generated SDK

The strongest practical benefit is on the TypeScript client.

The frontend imports the router type, not the server implementation:

// src/client/trpc.ts

import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/app-router";

export const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "/trpc",
    }),
  ],
});

Then the call site is typed from the server router:

const order = await trpc.order.create.mutate({
  customerId: "a0b1c2d3-6f0a-4ec8-9f42-57de0fb27a11",
  items: [
    {
      productId: "5c9480bb-0d7a-4c29-a477-9a8d90cf9b2e",
      quantity: 2,
    },
  ],
});

console.log(order.id);

If I rename the response field from id to orderId, the client call sites that still read order.id fail at compile time.

That is the kind of feedback loop I want in a full-stack TypeScript system. There is no generated SDK to refresh and no separate client type to remember to update.

This does not make testing disappear. It removes one class of contract drift.

I still write tests for behavior, authorization, critical flows, error mapping, and serialization choices. But I do not need a test whose only purpose is to prove that the TypeScript frontend and backend disagree about a property name. The compiler is already good at that.

Where the guarantee stops

tRPC’s type safety is strongest when the client and server are developed against the same router type. That usually means a monorepo, a shared package, or a controlled release process.

The compiler does not protect you from everything:

  • a deployed frontend talking to an older backend,
  • a semantic behavior change with the same response type,
  • a missing authorization rule,
  • a serialization mismatch around dates or big integers,
  • a Java, C#, native mobile, or partner client outside your TypeScript codebase.

That last case matters.

When the main consumers are not TypeScript clients, I do not pretend tRPC is enough. I want an explicit external contract: usually REST plus OpenAPI, or a different contract technology altogether.

That is a separate adapter problem. It deserves its own article.

For this article, the narrower claim is the useful one:

For first-party TypeScript clients compiled against the same router type, tRPC removes a large amount of DTO, SDK, and shape-level contract drift.

Transport is wiring, not architecture

tRPC does not need to own the HTTP server. The router can be mounted through Express, Fastify, Fetch, or another adapter. That choice belongs to infrastructure.

app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
    createContext,
  }),
);

Changing Express to Fastify should not touch the domain, use cases, or repository ports. If it does, the transport leaked too far inward.

What this looks like in practice

In a project like Fleet, I keep the tRPC layer in infrastructure:

src/
  domain/
    orders/
      order.ts
      order-id.ts
      order-errors.ts

  application/
    orders/
      create-order.ts
      list-orders.ts
      ports.ts

  infrastructure/
    trpc/
      trpc.ts
      context.ts
      routers/
        index.ts
        order.router.ts
      presenters/
        order-error.presenter.ts

The dependency direction is the important part:

infrastructure/trpc
  -> application
    -> domain

Nothing in domain/ imports from infrastructure/trpc. Nothing in application/ imports TRPCError.

The router imports use cases. Use cases import ports and domain objects. Composition happens at the edge.

That is the boundary.

When I would not use tRPC

tRPC is not a universal API strategy.

I avoid it as the primary contract when the main consumers are not TypeScript clients, when frontend and backend release independently with long compatibility windows, or when API versioning and partner onboarding are central product requirements. In those cases, I start with the contract the integration model needs: REST, OpenAPI, gRPC, Kafka schemas, or something else.

But for a first-party TypeScript web application, especially in a monorepo, tRPC is hard to beat as a driving adapter.

It gives me the adapter I wanted anyway:

  • validation at the boundary,
  • typed input and output,
  • thin translation code,
  • no framework leak into the use case,
  • a client contract inferred from implementation.

The rule I care about

tRPC is not the architecture.

Clean Architecture is not achieved by choosing tRPC instead of REST or NestJS. It is achieved by keeping the application core independent from the mechanism that calls it.

That is why I like this framing:

tRPC is a driving adapter, not an application layer.

Used that way, it fits cleanly.

The procedure validates the request, builds the command, calls the use case, and presents the result. The client gets excellent TypeScript feedback. The domain remains plain TypeScript.

That is the shape I want: useful infrastructure at the edge, boring use cases in the middle, and a domain model that does not know who is calling it.

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.