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.
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:
- Separate domain logic from transport concerns.
- Implement REST, GraphQL, and gRPC adapters.
- Map shared errors to protocol-specific responses.
- Test that all protocols behave consistently.
- 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) │
└─────────────────────────────────────────┘
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
});
}
}
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;
}
}
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
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
});
}
}
}
The controller performs three tasks:
- Reads
petIdfrom the route. - Reads
userIdfrom the request body. - 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)
);
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!
}
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'
}
});
}
}
}
};
The resolver maps domain errors to GraphQL error codes:
-
NotFoundErrorbecomesNOT_FOUND. -
ValidationErrorbecomesBAD_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
}
}
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;
}
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
});
}
}
}
The service maps the same domain errors to gRPC status codes:
-
NotFoundErrorbecomesNOT_FOUND. -
ValidationErrorbecomesINVALID_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
A practical project layout can follow the same separation:
src/
├── domain/
│ ├── entities/
│ ├── errors/
│ └── repositories/
├── application/
│ └── use-cases/
├── adapters/
│ ├── rest/
│ ├── graphql/
│ └── grpc/
└── infrastructure/
├── database/
└── cache/
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"
}
GraphQL
{
"data": {
"pet": {
"id": "019b4132-70aa-764f-b315-e2803d882a24",
"name": "Fluffy",
"species": "CAT"
}
}
}
gRPC
{
pet_id: "019b4132-70aa-764f-b315-e2803d882a24"
name: "Fluffy"
species: CAT
}
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');
}
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"
}
GraphQL:
{
"errors": [
{
"message": "Name must be at least 2 characters",
"extensions": {
"code": "BAD_USER_INPUT"
}
}
]
}
gRPC:
code: INVALID_ARGUMENT
message: "Name must be at least 2 characters"
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"
}
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
}
}
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"
}
Verify the response values and gRPC status code.
Build a Cross-Protocol Consistency Test
A basic consistency test should:
- Create or identify the same pet and user.
- Execute the adoption operation through REST.
- Execute the equivalent operation through GraphQL.
- Execute the equivalent operation through gRPC.
- 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) │
└─────────────────────────┘
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 │
└──────────────┘
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│
└──────────────┘ └───────────┘ └─────────────┘
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
-
.protofiles 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)