The API Dilemma: REST vs RPC?
For years, teams have debated REST vs RPC as if they were mutually exclusive choices. The truth? You need both. Modern applications benefit from a two-tier API strategy that leverages REST for external clients and RPC for internal services.
This isn’t about choosing sides-it’s about using the right tool for each job.
Understanding the Two Tiers
Tier 1: REST for External APIs (The Public Face)
Use REST when:
- Building public APIs for third-party developers
- Supporting web applications and mobile apps
- Providing discoverable, resource-oriented interfaces
- Caching and CDN integration is important
- Clients need to understand the API without extensive documentation
REST Strengths:
- ✅ Universal compatibility (every language speaks HTTP)
- ✅ Easy to understand and explore
- ✅ Great caching semantics
- ✅ Browser-friendly
- ✅ Self-documenting with proper HATEOAS
Tier 2: RPC for Internal Services (The Performance Layer)
Use RPC when:
- Building microservice-to-microservice communication
- Performance and efficiency are critical
- Type safety and code generation matter
- You control both client and server
- You need bidirectional streaming
RPC Strengths:
- ✅ Better performance (binary protocols)
- ✅ Strong typing and code generation
- ✅ Efficient network usage
- ✅ Built-in streaming support
- ✅ Natural method-call semantics
Real-World Architecture
┌─────────────────────────────────────────────────────┐
│ External Clients │
│ (Web, Mobile, Third-party apps) │
└────────────────────┬────────────────────────────────┘
│ REST/HTTP
│ (JSON, XML)
▼
┌─────────────────────────────────────────────────────┐
│ API Gateway │
│ (REST API, Rate Limiting, Auth) │
└────────────────────┬────────────────────────────────┘
│ RPC (gRPC/Connect)
│ (Protobuf, Efficient)
▼
┌─────────────────────────────────────────────────────┐
│ Internal Microservices │
│ (User Service, Order Service, Payment Service) │
└─────────────────────────────────────────────────────┘
Practical Implementation in Go
Let’s build a complete two-tier API system with a user service that exposes both REST and RPC interfaces.
Step 1: Define the Data Model
// user/v1/user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/example/api/user/v1;userv1";
message User {
string id = 1;
string username = 2;
string email = 3;
string full_name = 4;
int64 created_at = 5;
int64 updated_at = 6;
}
message CreateUserRequest {
string username = 1;
string email = 2;
string full_name = 3;
}
message CreateUserResponse {
User user = 1;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
message UpdateUserRequest {
string id = 1;
string username = 2;
string email = 3;
string full_name = 4;
}
message UpdateUserResponse {
User user = 1;
}
message DeleteUserRequest {
string id = 1;
}
message DeleteUserResponse {
bool success = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
Step 2: Implement the Core Business Logic
// internal/user/service.go
package user
import (
"context"
"errors"
"fmt"
"sync"
"time"
userv1 "github.com/example/api/user/v1"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrInvalidInput = errors.New("invalid input")
)
// Service is the core business logic for users
type Service struct {
users map[string]*userv1.User
mu sync.RWMutex
}
func NewService() *Service {
return &Service{
users: make(map[string]*userv1.User),
}
}
func (s *Service) CreateUser(ctx context.Context, username, email, fullName string) (*userv1.User, error) {
if username == "" || email == "" {
return nil, ErrInvalidInput
}
s.mu.Lock()
defer s.mu.Unlock()
// Check if user exists
for _, u := range s.users {
if u.Username == username || u.Email == email {
return nil, ErrUserExists
}
}
now := time.Now().Unix()
user := &userv1.User{
Id: fmt.Sprintf("usr_%d", len(s.users)+1),
Username: username,
Email: email,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
s.users[user.Id] = user
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*userv1.User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[id]
if !ok {
return nil, ErrUserNotFound
}
return user, nil
}
func (s *Service) UpdateUser(ctx context.Context, id, username, email, fullName string) (*userv1.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[id]
if !ok {
return nil, ErrUserNotFound
}
if username != "" {
user.Username = username
}
if email != "" {
user.Email = email
}
if fullName != "" {
user.FullName = fullName
}
user.UpdatedAt = time.Now().Unix()
return user, nil
}
func (s *Service) DeleteUser(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[id]; !ok {
return ErrUserNotFound
}
delete(s.users, id)
return nil
}
func (s *Service) ListUsers(ctx context.Context, pageSize int, pageToken string) ([]*userv1.User, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]*userv1.User, 0, len(s.users))
for _, user := range s.users {
users = append(users, user)
}
return users, "", nil
}
Step 3: Implement the RPC Server (Tier 2 - Internal)
// internal/rpc/server.go
package rpc
import (
"context"
"errors"
"connectrpc.com/connect"
userv1 "github.com/example/api/user/v1"
"github.com/example/api/user/v1/userv1connect"
"github.com/example/internal/user"
)
type UserServiceServer struct {
service *user.Service
}
func NewUserServiceServer(service *user.Service) *UserServiceServer {
return &UserServiceServer{
service: service,
}
}
func (s *UserServiceServer) CreateUser(
ctx context.Context,
req *connect.Request[userv1.CreateUserRequest],
) (*connect.Response[userv1.CreateUserResponse], error) {
user, err := s.service.CreateUser(ctx, req.Msg.Username, req.Msg.Email, req.Msg.FullName)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&userv1.CreateUserResponse{
User: user,
}), nil
}
func (s *UserServiceServer) GetUser(
ctx context.Context,
req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
user, err := s.service.GetUser(ctx, req.Msg.Id)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&userv1.GetUserResponse{
User: user,
}), nil
}
func (s *UserServiceServer) UpdateUser(
ctx context.Context,
req *connect.Request[userv1.UpdateUserRequest],
) (*connect.Response[userv1.UpdateUserResponse], error) {
user, err := s.service.UpdateUser(ctx, req.Msg.Id, req.Msg.Username, req.Msg.Email, req.Msg.FullName)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&userv1.UpdateUserResponse{
User: user,
}), nil
}
func (s *UserServiceServer) DeleteUser(
ctx context.Context,
req *connect.Request[userv1.DeleteUserRequest],
) (*connect.Response[userv1.DeleteUserResponse], error) {
err := s.service.DeleteUser(ctx, req.Msg.Id)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&userv1.DeleteUserResponse{
Success: true,
}), nil
}
func (s *UserServiceServer) ListUsers(
ctx context.Context,
req *connect.Request[userv1.ListUsersRequest],
) (*connect.Response[userv1.ListUsersResponse], error) {
users, nextToken, err := s.service.ListUsers(ctx, int(req.Msg.PageSize), req.Msg.PageToken)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&userv1.ListUsersResponse{
Users: users,
NextPageToken: nextToken,
}), nil
}
func mapError(err error) error {
switch {
case errors.Is(err, user.ErrUserNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, user.ErrUserExists):
return connect.NewError(connect.CodeAlreadyExists, err)
case errors.Is(err, user.ErrInvalidInput):
return connect.NewError(connect.CodeInvalidArgument, err)
default:
return connect.NewError(connect.CodeInternal, err)
}
}
Step 4: Implement the REST API Gateway (Tier 1 - External)
// internal/rest/handler.go
package rest
import (
"encoding/json"
"net/http"
"strconv"
"github.com/example/internal/user"
"github.com/go-chi/chi/v5"
)
type Handler struct {
service *user.Service
}
func NewHandler(service *user.Service) *Handler {
return &Handler{
service: service,
}
}
// REST request/response models
type CreateUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
}
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type UpdateUserRequest struct {
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err)
return
}
user, err := h.service.CreateUser(r.Context(), req.Username, req.Email, req.FullName)
if err != nil {
handleServiceError(w, err)
return
}
respondJSON(w, http.StatusCreated, toUserResponse(user))
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user, err := h.service.GetUser(r.Context(), id)
if err != nil {
handleServiceError(w, err)
return
}
respondJSON(w, http.StatusOK, toUserResponse(user))
}
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body", err)
return
}
user, err := h.service.UpdateUser(r.Context(), id, req.Username, req.Email, req.FullName)
if err != nil {
handleServiceError(w, err)
return
}
respondJSON(w, http.StatusOK, toUserResponse(user))
}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.service.DeleteUser(r.Context(), id); err != nil {
handleServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
if pageSize <= 0 {
pageSize = 50
}
pageToken := r.URL.Query().Get("page_token")
users, nextToken, err := h.service.ListUsers(r.Context(), pageSize, pageToken)
if err != nil {
handleServiceError(w, err)
return
}
response := map[string]interface{}{
"users": toUserResponses(users),
"next_page_token": nextToken,
}
respondJSON(w, http.StatusOK, response)
}
// Helper functions
func toUserResponse(u interface{ GetId() string; GetUsername() string; GetEmail() string; GetFullName() string; GetCreatedAt() int64; GetUpdatedAt() int64 }) *UserResponse {
return &UserResponse{
ID: u.GetId(),
Username: u.GetUsername(),
Email: u.GetEmail(),
FullName: u.GetFullName(),
CreatedAt: u.GetCreatedAt(),
UpdatedAt: u.GetUpdatedAt(),
}
}
func toUserResponses(users []interface{ GetId() string; GetUsername() string; GetEmail() string; GetFullName() string; GetCreatedAt() int64; GetUpdatedAt() int64 }) []*UserResponse {
responses := make([]*UserResponse, len(users))
for i, u := range users {
responses[i] = toUserResponse(u)
}
return responses
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(status),
Message: message,
})
}
func handleServiceError(w http.ResponseWriter, err error) {
switch err {
case user.ErrUserNotFound:
respondError(w, http.StatusNotFound, "user not found", err)
case user.ErrUserExists:
respondError(w, http.StatusConflict, "user already exists", err)
case user.ErrInvalidInput:
respondError(w, http.StatusBadRequest, "invalid input", err)
default:
respondError(w, http.StatusInternalServerError, "internal server error", err)
}
}
// RegisterRoutes registers all REST routes
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Route("/api/v1/users", func(r chi.Router) {
r.Post("/", h.CreateUser)
r.Get("/", h.ListUsers)
r.Get("/{id}", h.GetUser)
r.Put("/{id}", h.UpdateUser)
r.Delete("/{id}", h.DeleteUser)
})
}
Step 5: Main Application with Both Tiers
// cmd/server/main.go
package main
import (
"log"
"net/http"
"github.com/example/internal/rest"
"github.com/example/internal/rpc"
"github.com/example/internal/user"
"github.com/example/user/v1/userv1connect"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func main() {
// Create the core service (shared between REST and RPC)
userService := user.NewService()
// Create router
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// Tier 1: REST API (External)
restHandler := rest.NewHandler(userService)
restHandler.RegisterRoutes(r)
// Tier 2: RPC API (Internal)
rpcServer := rpc.NewUserServiceServer(userService)
path, handler := userv1connect.NewUserServiceHandler(rpcServer)
r.Mount(path, handler)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// API documentation
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`
<h1>Two-Tier API Server</h1>
<h2>REST API (External - Tier 1)</h2>
<ul>
<li>POST /api/v1/users - Create user</li>
<li>GET /api/v1/users - List users</li>
<li>GET /api/v1/users/:id - Get user</li>
<li>PUT /api/v1/users/:id - Update user</li>
<li>DELETE /api/v1/users/:id - Delete user</li>
</ul>
<h2>RPC API (Internal - Tier 2)</h2>
<ul>
<li>POST /user.v1.UserService/CreateUser</li>
<li>POST /user.v1.UserService/GetUser</li>
<li>POST /user.v1.UserService/UpdateUser</li>
<li>POST /user.v1.UserService/DeleteUser</li>
<li>POST /user.v1.UserService/ListUsers</li>
</ul>
`))
})
log.Println("Server starting on :8080")
log.Println("REST API: http://localhost:8080/api/v1/users")
log.Println("RPC API: http://localhost:8080/user.v1.UserService")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatal(err)
}
}
Step 6: Testing Both Tiers
Testing REST API (External Client):
# Create a user via REST
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "[email protected]",
"full_name": "John Doe"
}'
# Get user via REST
curl http://localhost:8080/api/v1/users/usr_1
# Update user via REST
curl -X PUT http://localhost:8080/api/v1/users/usr_1 \
-H "Content-Type: application/json" \
-d '{
"full_name": "John Smith"
}'
# List users via REST
curl http://localhost:8080/api/v1/users?page_size=10
Testing RPC API (Internal Service):
// internal/client/client.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"connectrpc.com/connect"
userv1 "github.com/example/api/user/v1"
"github.com/example/api/user/v1/userv1connect"
)
func main() {
client := userv1connect.NewUserServiceClient(
http.DefaultClient,
"http://localhost:8080",
)
ctx := context.Background()
// Create user via RPC
createResp, err := client.CreateUser(ctx, connect.NewRequest(&userv1.CreateUserRequest{
Username: "janedoe",
Email: "[email protected]",
FullName: "Jane Doe",
}))
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created: %+v\n", createResp.Msg.User)
// Get user via RPC
getResp, err := client.GetUser(ctx, connect.NewRequest(&userv1.GetUserRequest{
Id: createResp.Msg.User.Id,
}))
if err != nil {
log.Fatal(err)
}
fmt.Printf("Retrieved: %+v\n", getResp.Msg.User)
}
API Gateway Pattern
For production systems, add an API Gateway to manage the boundary:
// internal/gateway/gateway.go
package gateway
import (
"net/http"
"net/http/httputil"
"net/url"
"github.com/go-chi/chi/v5"
)
type Gateway struct {
restBackend *httputil.ReverseProxy
rpcBackend *httputil.ReverseProxy
}
func NewGateway(restURL, rpcURL string) *Gateway {
restBackend, _ := url.Parse(restURL)
rpcBackend, _ := url.Parse(rpcURL)
return &Gateway{
restBackend: httputil.NewSingleHostReverseProxy(restBackend),
rpcBackend: httputil.NewSingleHostReverseProxy(rpcBackend),
}
}
func (g *Gateway) RegisterRoutes(r chi.Router) {
// Public REST API - accessible to external clients
r.Route("/api/*", func(r chi.Router) {
r.Use(RateLimitMiddleware())
r.Use(AuthMiddleware())
r.HandleFunc("/*", g.restBackend.ServeHTTP)
})
// Internal RPC API - only accessible from internal network
r.Route("/rpc/*", func(r chi.Router) {
r.Use(InternalOnlyMiddleware())
r.HandleFunc("/*", g.rpcBackend.ServeHTTP)
})
}
func RateLimitMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Rate limiting logic
next.ServeHTTP(w, r)
})
}
}
func AuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Authentication logic
next.ServeHTTP(w, r)
})
}
}
func InternalOnlyMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if request is from internal network
// Reject if external
next.ServeHTTP(w, r)
})
}
}
Benefits of the Two-Tier Approach
1. Best Tool for Each Job
- REST for discoverable, cacheable public APIs
- RPC for efficient internal communication
2. Performance Optimization
- External clients get JSON (easy to consume)
- Internal services use Protobuf (fast and compact)
3. Security Layers
- Public REST API has rate limiting, auth, validation
- Internal RPC API trusts network boundaries
4. Independent Evolution
- Change internal RPC without breaking external REST
- Maintain backward compatibility on public API
- Refactor internal services freely
5. Developer Experience
- External developers get REST (familiar, easy)
- Internal teams get RPC (fast, type-safe)
Common Patterns and Best Practices
Pattern 1: API Gateway Translation
// Gateway translates REST to RPC
func (h *Gateway) CreateUser(w http.ResponseWriter, r *http.Request) {
// Parse REST request
var restReq CreateUserRequest
json.NewDecoder(r.Body).Decode(&restReq)
// Call internal RPC service
rpcResp, err := h.userClient.CreateUser(r.Context(), connect.NewRequest(&userv1.CreateUserRequest{
Username: restReq.Username,
Email: restReq.Email,
FullName: restReq.FullName,
}))
if err != nil {
handleError(w, err)
return
}
// Convert RPC response to REST
restResp := toRESTUser(rpcResp.Msg.User)
json.NewEncoder(w).Encode(restResp)
}
Pattern 2: Shared Business Logic
Keep business logic in a shared service layer that both REST and RPC adapters use.
Pattern 3: Protocol Buffers as Source of Truth
Define your data models in .proto files, generate code for both REST and RPC.
When NOT to Use Two-Tier
- Simple applications - overhead may not be worth it
- Pure internal services - just use RPC
- Pure external APIs - just use REST
- Serverless architectures - REST is often sufficient
Conclusion
The two-tier API strategy isn’t about REST vs RPC-it’s about leveraging both for their strengths. REST provides an accessible, cacheable public interface while RPC delivers the performance and type safety your internal services need.
Key Takeaways:
- ✅ Use REST for external, public-facing APIs
- ✅ Use RPC for internal, service-to-service communication
- ✅ Share business logic between both tiers
- ✅ Let your API Gateway handle translation and security
- ✅ Protocol Buffers can define both REST and RPC contracts
- ✅ Maintain independent evolution of each tier
By combining REST and RPC strategically, you get the best of both worlds: developer-friendly external APIs and high-performance internal communication.