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