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.

Additional Resources