gRPC — Your Handler Is a Class
You wrote the contract in a .proto file. Then the implementation turned into a function handed to server.addService(), closing over whatever it needed because a function has nowhere to put a constructor.
That function can be a class now. The container builds it. The adapter puts it on the wire.

Familiar, on purpose
@inversifyjs/grpc-js builds a @grpc/grpc-js server from classes registered in an Inversify container. Decorators, status errors, guards, middleware, interceptors, pipes, and error filters live in @inversifyjs/grpc-core.
If you have shipped an HTTP API with this framework, you already know the rhythm. Declare the class. Mark the method. Return the value. Let the pipeline run.
Return the message
import { RPC, Service } from '@inversifyjs/grpc-core';
import {
type HeroRequest,
type HeroResponse,
HeroServiceService,
} from './generated/hero.js';
@Service(HeroServiceService)
export class HeroService {
@RPC('getHero')
public getHero(call: { request: HeroRequest }): HeroResponse {
return {
name: call.request.id,
};
}
}
@Service() stores the generated service definition and applies @injectable(). @RPC('getHero') is the key ts-proto emits for rpc GetHero when you generate with outputServices=grpc-js.
A unary method returns the response message, and the adapter sends it. Bind HeroService before build(). The adapter registers every bound @Service() class and skips the ones you left unbound.
build() hands back the @grpc/grpc-js Server. Opening the port stays in your code, so a test can bind an ephemeral port and production can pass ServerCredentials.createSsl. Already constructed a Server? Pass it to the adapter. Your services are registered on that instance, and build() returns it.
One proto, four conversations
syntax = "proto3";
package hero.v1;
service HeroService {
rpc GetHero (HeroRequest) returns (HeroResponse);
}
service HeroUploadService {
rpc UploadHeroes (stream UploadRequest) returns (UploadResponse);
}
service HeroListService {
rpc ListHeroes (HeroRequest) returns (stream HeroResponse);
}
service HeroChatService {
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message HeroRequest {
string id = 1;
}
message HeroResponse {
string name = 1;
}
message UploadRequest {
string name = 1;
}
message UploadResponse {
repeated string names = 1;
}
message ChatMessage {
string text = 1;
}
requestStream and responseStream on the service definition choose how the method sees the call.
| Kind | Request | Response | Your method |
|---|---|---|---|
| Unary | one message | one message | Returns the response |
| Client streaming | a stream | one message | Returns the response when the input ends |
| Server streaming | one message | a stream | Writes each message, then calls end() |
| Bidirectional | a stream | a stream | Reads and writes the same call |
Unary and client-streaming methods can return the message and let the adapter send it. Return undefined when you have already finished the call yourself, for example through @Callback(). Server-streaming and bidirectional methods own the call. You write the messages, and the adapter leaves the return value alone.
A server stream is a few writes and an end():
import { type ServerWritableStream } from '@grpc/grpc-js';
import { RPC, Service } from '@inversifyjs/grpc-core';
import {
heroListServiceDefinition,
type HeroRequest,
type HeroResponse,
} from './loadHeroServiceDefinition.js';
@Service(heroListServiceDefinition)
export class HeroListService {
@RPC('ListHeroes')
public listHeroes(
call: ServerWritableStream<HeroRequest, HeroResponse>,
): void {
const id: string = call.request.id;
call.write({
name: `${id}-a`,
});
call.write({
name: `${id}-b`,
});
call.end();
}
}
That sample loads hero.proto with @grpc/proto-loader, so @RPC() uses the name from the file, ListHeroes. Generate the same service with ts-proto and outputServices=grpc-js, and the key follows the generated definition, the way GetHero became getHero above. The string you pass to @RPC() has to be a key of the definition you passed to @Service().
The constructor was the point
import { RPC, Service } from '@inversifyjs/grpc-core';
import { inject, injectable } from 'inversify';
import {
type HeroRequest,
type HeroResponse,
heroServiceDefinition,
} from './loadHeroServiceDefinition.js';
@injectable()
export class HeroRepository {
public findName(id: string): string {
return id;
}
}
@Service(heroServiceDefinition)
export class RepositoryHeroService {
readonly #heroRepository: HeroRepository;
constructor(@inject(HeroRepository) heroRepository: HeroRepository) {
this.#heroRepository = heroRepository;
}
@RPC('GetHero')
public getHero(call: { request: HeroRequest }): HeroResponse {
return {
name: this.#heroRepository.findName(call.request.id),
};
}
}
Bind the repository. Bind the service. GetHero uses the repository the container passed into the constructor, the same way any other class in the process gets its collaborators.
@Service() accepts a scope of 'Singleton', 'Transient', or 'Request'. Leave it off and the container default applies. @RPC() methods are inherited. A subclass method with the same RPC name replaces the parent, and @Service() goes on the class you bind.
The call runs a pipeline
Each RPC goes through the same sequence:
- Pre-handler middleware. Global, then class, then method.
- Guards, in that same order.
- Interceptors. Class interceptors wrap method interceptors, which wrap global interceptors.
- Pipes on decorated parameters. Global pipes run first.
- The method.
- Post-handler middleware. Class, then method, then global.
A guard that returns false finishes the call with PERMISSION_DENIED. Throw a GrpcError from a guard, middleware, an interceptor, a pipe, or the method, and the client receives that status, on unary calls and on streams. An error that is not a GrpcError, and that no error filter handles, becomes status UNKNOWN with the details Unknown. With logging enabled, the original error is written to the log.
import { NotFoundGrpcError, RPC, Service } from '@inversifyjs/grpc-core';
import {
type HeroRequest,
type HeroResponse,
heroServiceDefinition,
} from './loadHeroServiceDefinition.js';
@Service(heroServiceDefinition)
export class NotFoundHeroService {
@RPC('GetHero')
public getHero(call: { request: HeroRequest }): HeroResponse {
if (call.request.id === 'missing') {
throw new NotFoundGrpcError(`Hero ${call.request.id} was not found`);
}
return {
name: call.request.id,
};
}
}
Every non-OK gRPC status has a class: NotFoundGrpcError, UnauthenticatedGrpcError, UnavailableGrpcError, and the rest. Pass your own details, and optional metadata, when the default string is too vague.
An error filter translates one of your own exceptions into one of those statuses. Throw a GrpcError from catch, and that is what the client sees. Method filters are chosen before class filters, which are chosen before global filters.
Interceptors can reshape a returned unary or client-streaming message before it is sent. A custom parameter decorator can hand the method a value computed from the call, and pipes can transform that value before the method runs. @Call() and @Callback() are the built-in way to take the raw call or the unary callback when you want them. Register global handlers on the adapter before build().
Switch it on
npm install inversify reflect-metadata @grpc/grpc-js @bufbuild/protobuf @inversifyjs/grpc-core @inversifyjs/grpc-js
npm install -D grpc-tools ts-proto
Enable experimentalDecorators and emitDecoratorMetadata. Import reflect-metadata once, before any decorated class loads.
Leave logger unset and build() prints each service and the kind of each RPC. logger: false keeps that quiet. Pass a Logger to use yours.
The getting started guide walks through code generation and the server bootstrap. From there: services, RPCs, guards, and errors.
A service definition that deserves a better server? Open an issue on GitHub or bring it to Discord.
