DEV Community

Preecha
Preecha

Posted on

How Do You Build a Multi-Protocol API with REST, GraphQL, and gRPC?

TL;DR

Build multi-protocol APIs by separating business logic from protocol layers. Create a shared domain layer, then add REST, GraphQL, and gRPC adapters on top. Modern PetstoreAPI demonstrates this architecture with consistent data models across all three protocols.

Try Apidog today

Introduction

Your API may serve web clients, mobile apps, and internal microservices:

  • Web clients often use REST for its simplicity.
  • Mobile apps may use GraphQL to request only the data they need.
  • Internal microservices may use gRPC for efficient service-to-service communication.

You do not need three independent implementations. Instead, build one application with three protocol layers. The business logic remains shared, while each adapter handles protocol-specific input, output, and error mapping.

This architecture is known as a multi-protocol API.

Modern PetstoreAPI implements REST, GraphQL, and gRPC around a shared core. The same pet-store operations and business rules are available through all three protocols.

In this guide, you’ll learn how to:

  1. Separate domain logic from transport concerns.
  2. Implement REST, GraphQL, and gRPC adapters.
  3. Map shared errors to protocol-specific responses.
  4. Test that all protocols behave consistently.
  5. Choose a deployment strategy.

Multi-Protocol Architecture

A multi-protocol API separates responsibilities into layers:

┌─────────────────────────────────────────┐
│  Protocol Layer (REST/GraphQL/gRPC)     │
├─────────────────────────────────────────┤
│  Application Layer (Use Cases)          │
├─────────────────────────────────────────┤
│  Domain Layer (Business Logic)          │
├─────────────────────────────────────────┤
│  Data Layer (Database, Cache)           │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Each layer has a focused responsibility:

  • Protocol layer: Handles HTTP requests, GraphQL operations, and gRPC calls.
  • Application layer: Orchestrates use cases such as creating a pet or placing an order.
  • Domain layer: Enforces business rules, validation, and calculations.
  • Data layer: Manages persistence and caching.

Key Principles

1. Keep the core protocol-agnostic

Business logic should not depend on HTTP status codes, GraphQL resolver APIs, or gRPC metadata. It should operate on domain objects and application-level errors.

2. Keep protocol adapters thin

Adapters translate protocol-specific input into use-case arguments and map results back into protocol-specific responses. They should not implement business rules.

3. Share domain models and validation

All protocols should invoke the same domain models and validation logic. This prevents REST, GraphQL, and gRPC from gradually developing different behavior.

4. Allow independent deployment when necessary

The protocols can run in one service or be deployed separately. The correct choice depends on your scaling and operational requirements.

Build the Shared Domain Layer

The domain layer is the foundation of the application. It contains entities, value objects, business rules, and domain errors.

Define Domain Models

The Pet model below does not know whether it was created through REST, GraphQL, or gRPC:

class Pet {
  id: string;
  name: string;
  species: Species;
  status: PetStatus;
  price: number;

  constructor(data: PetData) {
    this.validate(data);
    Object.assign(this, data);
  }

  validate(data: PetData): void {
    if (!data.name || data.name.length < 2) {
      throw new ValidationError('Name must be at least 2 characters');
    }

    if (data.price < 0) {
      throw new ValidationError('Price cannot be negative');
    }
  }

  adopt(userId: string): Order {
    if (this.status !== PetStatus.AVAILABLE) {
      throw new BusinessError('Pet is not available for adoption');
    }

    this.status = PetStatus.ADOPTED;

    return new Order({
      petId: this.id,
      userId,
      total: this.price
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The validation and adoption rule are shared automatically because every protocol eventually calls this model.

Implement Use Cases

Use cases coordinate domain objects and repositories. They should expose application operations without knowing anything about the transport layer:

class AdoptPetUseCase {
  constructor(
    private petRepository: PetRepository,
    private orderRepository: OrderRepository
  ) {}

  async execute(petId: string, userId: string): Promise<Order> {
    const pet = await this.petRepository.findById(petId);

    if (!pet) {
      throw new NotFoundError('Pet not found');
    }

    const order = pet.adopt(userId);

    await this.petRepository.save(pet);
    await this.orderRepository.save(order);

    return order;
  }
}
Enter fullscreen mode Exit fullscreen mode

This use case can now be called by a REST controller, GraphQL resolver, or gRPC service.

A useful dependency direction looks like this:

REST controller ───────┐
GraphQL resolver ──────┼──> AdoptPetUseCase ───> Domain model
gRPC service ──────────┘             │
                                     └─────────> Repositories
Enter fullscreen mode Exit fullscreen mode

The protocol layers depend on the application layer. The application and domain layers do not depend on a specific protocol.

Add the REST Protocol Layer

The REST adapter translates URL parameters and JSON request bodies into use-case arguments.

Implement a REST Controller

class PetsController {
  constructor(private adoptPetUseCase: AdoptPetUseCase) {}

  async adoptPet(req: Request, res: Response): Promise<void> {
    try {
      const { petId } = req.params;
      const { userId } = req.body;

      const order = await this.adoptPetUseCase.execute(petId, userId);

      res.status(201).json({
        id: order.id,
        petId: order.petId,
        userId: order.userId,
        total: order.total,
        status: order.status
      });
    } catch (error) {
      this.handleError(error as Error, res);
    }
  }

  private handleError(error: Error, res: Response): void {
    if (error instanceof NotFoundError) {
      res.status(404).json({
        type: 'https://petstoreapi.com/errors/not-found',
        title: 'Not Found',
        status: 404,
        detail: error.message
      });
    } else if (error instanceof ValidationError) {
      res.status(400).json({
        type: 'https://petstoreapi.com/errors/validation-error',
        title: 'Validation Error',
        status: 400,
        detail: error.message
      });
    } else {
      res.status(500).json({
        type: 'https://petstoreapi.com/errors/internal-error',
        title: 'Internal Server Error',
        status: 500
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The controller performs three tasks:

  1. Reads petId from the route.
  2. Reads userId from the request body.
  3. Converts the use-case result or error into an HTTP response.

It does not decide whether a pet can be adopted. That rule belongs to the domain model.

Register the Route

app.post('/v1/pets/:petId/adopt', (req, res) =>
  petsController.adoptPet(req, res)
);
Enter fullscreen mode Exit fullscreen mode

Modern PetstoreAPI exposes its REST resources under the /v1 path.

Add the GraphQL Protocol Layer

The GraphQL adapter exposes the same operation through a schema and resolver.

Define the GraphQL Schema

type Pet {
  id: ID!
  name: String!
  species: Species!
  status: PetStatus!
  price: Float!
}

type Order {
  id: ID!
  petId: ID!
  userId: ID!
  total: Float!
  status: OrderStatus!
}

type Mutation {
  adoptPet(petId: ID!, userId: ID!): Order!
}
Enter fullscreen mode Exit fullscreen mode

The schema controls which fields a client can request. The underlying operation remains the shared AdoptPetUseCase.

Implement the Resolver

const resolvers = {
  Mutation: {
    adoptPet: async (
      _parent: unknown,
      args: { petId: string; userId: string },
      context: Context
    ): Promise<Order> => {
      try {
        return await context.adoptPetUseCase.execute(
          args.petId,
          args.userId
        );
      } catch (error) {
        const message =
          error instanceof Error ? error.message : 'Internal server error';

        throw new GraphQLError(message, {
          extensions: {
            code:
              error instanceof NotFoundError
                ? 'NOT_FOUND'
                : error instanceof ValidationError
                  ? 'BAD_USER_INPUT'
                  : 'INTERNAL_SERVER_ERROR'
          }
        });
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The resolver maps domain errors to GraphQL error codes:

  • NotFoundError becomes NOT_FOUND.
  • ValidationError becomes BAD_USER_INPUT.
  • Unexpected errors become INTERNAL_SERVER_ERROR.

Call the GraphQL Mutation

mutation {
  adoptPet(
    petId: "019b4132-70aa-764f-b315-e2803d882a24"
    userId: "user-123"
  ) {
    id
    total
    status
  }
}
Enter fullscreen mode Exit fullscreen mode

GraphQL clients can request only the fields they need, while the resolver still executes the same domain operation as REST.

Add the gRPC Protocol Layer

The gRPC adapter maps Protocol Buffer messages to the shared use case.

Define the Protocol Buffer Contract

syntax = "proto3";

package petstore.v1;

service PetService {
  rpc AdoptPet(AdoptPetRequest) returns (AdoptPetResponse);
}

message AdoptPetRequest {
  string pet_id = 1;
  string user_id = 2;
}

message AdoptPetResponse {
  string order_id = 1;
  string pet_id = 2;
  string user_id = 3;
  double total = 4;
  string status = 5;
}
Enter fullscreen mode Exit fullscreen mode

The .proto file defines the wire contract independently of the domain model. The service implementation is responsible for translating between the generated request and response types and the application layer.

Implement the gRPC Service

class PetServiceImpl implements IPetService {
  constructor(private adoptPetUseCase: AdoptPetUseCase) {}

  async adoptPet(
    call: ServerUnaryCall<AdoptPetRequest, AdoptPetResponse>,
    callback: sendUnaryData<AdoptPetResponse>
  ): Promise<void> {
    try {
      const { petId, userId } = call.request;

      const order = await this.adoptPetUseCase.execute(petId, userId);

      callback(null, {
        orderId: order.id,
        petId: order.petId,
        userId: order.userId,
        total: order.total,
        status: order.status
      });
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'Internal server error';

      callback({
        code:
          error instanceof NotFoundError
            ? status.NOT_FOUND
            : error instanceof ValidationError
              ? status.INVALID_ARGUMENT
              : status.INTERNAL,
        message
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The service maps the same domain errors to gRPC status codes:

  • NotFoundError becomes NOT_FOUND.
  • ValidationError becomes INVALID_ARGUMENT.
  • Unexpected errors become INTERNAL.

The business rule remains in Pet.adopt(), not in the gRPC service.

How Modern PetstoreAPI Organizes the Code

Modern PetstoreAPI demonstrates this structure:

Modern PetstoreAPI
├── Domain Layer
│   ├── Pet entity
│   ├── Order entity
│   └── Use cases
│       ├── CreatePet
│       ├── AdoptPet
│       └── PlaceOrder
├── REST Layer
│   ├── /v1/pets
│   ├── /v1/orders
│   └── OpenAPI 3.2 specification
├── GraphQL Layer
│   ├── Query resolvers
│   ├── Mutation resolvers
│   └── GraphQL schema
└── gRPC Layer
    ├── PetService
    ├── OrderService
    └── .proto definitions
Enter fullscreen mode Exit fullscreen mode

A practical project layout can follow the same separation:

src/
├── domain/
│   ├── entities/
│   ├── errors/
│   └── repositories/
├── application/
│   └── use-cases/
├── adapters/
│   ├── rest/
│   ├── graphql/
│   └── grpc/
└── infrastructure/
    ├── database/
    └── cache/
Enter fullscreen mode Exit fullscreen mode

The exact directory names are not important. The dependency boundaries are.

Keep Data Models Consistent

All protocols should expose the same underlying pet data:

REST

{
  "id": "019b4132-70aa-764f-b315-e2803d882a24",
  "name": "Fluffy",
  "species": "CAT"
}
Enter fullscreen mode Exit fullscreen mode

GraphQL

{
  "data": {
    "pet": {
      "id": "019b4132-70aa-764f-b315-e2803d882a24",
      "name": "Fluffy",
      "species": "CAT"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

gRPC

{
  pet_id: "019b4132-70aa-764f-b315-e2803d882a24"
  name: "Fluffy"
  species: CAT
}
Enter fullscreen mode Exit fullscreen mode

The envelope and field naming differ, but the underlying resource is the same.

Share Validation

Validation belongs in the domain layer:

if (name.length < 2) {
  throw new ValidationError('Name must be at least 2 characters');
}
Enter fullscreen mode Exit fullscreen mode

Each adapter then maps the error to its own response format.

REST:

{
  "type": "https://petstoreapi.com/errors/validation-error",
  "status": 400,
  "detail": "Name must be at least 2 characters"
}
Enter fullscreen mode Exit fullscreen mode

GraphQL:

{
  "errors": [
    {
      "message": "Name must be at least 2 characters",
      "extensions": {
        "code": "BAD_USER_INPUT"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

gRPC:

code: INVALID_ARGUMENT
message: "Name must be at least 2 characters"
Enter fullscreen mode Exit fullscreen mode

When a rule changes, update the domain logic once and verify each adapter’s mapping.

Test Multi-Protocol APIs with Apidog

Apidog supports testing REST, GraphQL, and gRPC in one tool. This makes it possible to verify that different protocol adapters invoke the same behavior.

Test the REST Endpoint

POST https://petstoreapi.com/v1/pets/019b4132-70aa-764f-b315-e2803d882a24/adopt
Content-Type: application/json

{
  "userId": "user-123"
}
Enter fullscreen mode Exit fullscreen mode

Verify the HTTP status, response fields, and error format.

Test the GraphQL Mutation

mutation {
  adoptPet(
    petId: "019b4132-70aa-764f-b315-e2803d882a24"
    userId: "user-123"
  ) {
    id
    total
  }
}
Enter fullscreen mode Exit fullscreen mode

Verify that the selected fields match the corresponding REST response.

Test the gRPC Service

grpc.petstore.v1.PetService/AdoptPet

{
  "pet_id": "019b4132-70aa-764f-b315-e2803d882a24",
  "user_id": "user-123"
}
Enter fullscreen mode Exit fullscreen mode

Verify the response values and gRPC status code.

Build a Cross-Protocol Consistency Test

A basic consistency test should:

  1. Create or identify the same pet and user.
  2. Execute the adoption operation through REST.
  3. Execute the equivalent operation through GraphQL.
  4. Execute the equivalent operation through gRPC.
  5. Compare the resulting order fields and business outcome.

For error cases, use the same invalid input across all three protocols and verify that each adapter returns the expected protocol-specific representation of the same domain error.

Apidog can automate this cross-protocol testing workflow.

Deployment Strategies

Strategy 1: Single Service

Run all protocols in one service:

┌─────────────────────────┐
│  PetstoreAPI Service    │
│  ├── REST (port 8080)   │
│  ├── GraphQL (port 8081)│
│  └── gRPC (port 50051)  │
└─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Pros

  • Simple deployment
  • Shared resources
  • Straightforward local development

Cons

  • All protocols scale together
  • A deployment affects every protocol

Strategy 2: Separate Services

Deploy each protocol independently:

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│ REST Service │  │ GraphQL Svc  │  │ gRPC Service │
│ (port 8080)  │  │ (port 8081)  │  │ (port 50051) │
└──────────────┘  └──────────────┘  └──────────────┘
       │                 │                  │
       └─────────────────┴──────────────────┘
                         │
                  ┌──────────────┐
                  │ Shared Core  │
                  │   Library    │
                  └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Pros

  • Independent scaling
  • Protocol isolation
  • Separate deployment schedules

Cons

  • More deployment and observability overhead
  • Shared core versioning must be managed carefully

Strategy 3: API Gateway

Use a gateway to route requests to protocol-specific backends:

                    ┌─────────────┐
                    │ API Gateway │
                    └─────────────┘
                           │
            ┌──────────────┼──────────────┐
            │              │              │
    ┌───────▼──────┐ ┌─────▼─────┐ ┌──────▼──────┐
    │ REST Backend │ │ GraphQL   │ │ gRPC Backend│
    └──────────────┘ └───────────┘ └─────────────┘
Enter fullscreen mode Exit fullscreen mode

Pros

  • Centralized routing
  • Centralized rate limiting
  • Centralized authentication

Cons

  • Additional latency
  • More gateway configuration and operational complexity

Modern PetstoreAPI uses the single-service strategy for simplicity.

Conclusion

Multi-protocol APIs provide client flexibility without requiring duplicated business logic. The key is to keep protocol layers separate from the shared application and domain layers.

With this approach:

  • REST handles HTTP resources and status codes.
  • GraphQL handles flexible queries and mutations.
  • gRPC handles Protocol Buffer contracts and service calls.
  • The domain layer enforces the same business rules for every protocol.

Modern PetstoreAPI demonstrates this architecture with shared data models, shared validation, and protocol-specific adapters. Clients can use REST for simplicity, GraphQL for flexibility, or gRPC for service-to-service performance while accessing the same pet store behavior.

Use Apidog to test all protocols together and verify that equivalent operations remain consistent. Explore Modern PetstoreAPI for additional examples of multi-protocol API architecture.

FAQ

Do I need to support all three protocols?

No. Start with REST for public APIs. Add GraphQL when clients need flexible data fetching, or add gRPC for internal microservices. Introduce protocols only when there is a clear use case.

How do I keep protocols consistent?

Put business logic and validation in a shared domain layer. Protocol adapters should translate formats and map errors, not implement separate business rules. Test equivalent operations across all protocols.

Can I version protocols independently?

Yes. REST can be at v2 while GraphQL is at v1. However, independent versioning increases complexity. Keep protocol versions aligned when possible.

How do I handle authentication across protocols?

Use the same authentication model across protocols:

  • REST passes Bearer [REDACTED] in HTTP headers.
  • GraphQL passes the same tokens through the request context.
  • gRPC passes credentials through metadata.

The protocol adapters can extract credentials, while shared application logic handles authorization rules.

What about WebSocket and SSE?

WebSocket and SSE are transport mechanisms for real-time updates rather than replacements for REST, GraphQL, or gRPC APIs. You can add them alongside the other protocols. Modern PetstoreAPI includes both.

How do I document multi-protocol APIs?

Use the format associated with each protocol:

  • OpenAPI for REST
  • A GraphQL schema for GraphQL
  • .proto files for gRPC

Modern PetstoreAPI provides all three at https://docs.petstoreapi.com/.

Can I use different databases for different protocols?

Yes, but separate data layers introduce consistency challenges. Prefer a shared data layer unless there is a strong reason to use different databases. Keep format-specific translation inside the protocol adapters.

How do I test multi-protocol APIs?

Use Apidog to test all protocols in one tool. Create test suites that execute the same operations through REST, GraphQL, and gRPC, then compare their results and error behavior.

Top comments (0)