When building microservices, choosing the right communication protocol is crucial. While REST and event-driven architectures have their place, gRPC offers a compelling alternative with strong typing, efficient binary serialization, and built-in support for streaming. In this guide, we’ll build a complete note-sharing application using gRPC microservices in Go, with a Vue.js frontend connected through a WebSocket gateway.
Why gRPC for Microservices?
gRPC brings several advantages to microservice architectures:
- Strong Typing: Protocol Buffers provide type-safe contracts between services
- Performance: Binary serialization is faster and more compact than JSON
- Code Generation: Auto-generate client and server code from .proto files
- Streaming: Built-in support for bidirectional streaming
- Language Agnostic: Works across many programming languages
- HTTP/2: Connection multiplexing, header compression, and server push
However, browsers can’t make native gRPC calls. That’s where our WebSocket gateway comes in.
System Architecture
We’ll build four components:
- User Service: gRPC service for user management (CRUD operations)
- Note Service: gRPC service for notes that calls User Service for validation
- API Gateway: gRPC-Web proxy with WebSocket support for browsers
- Frontend: Vue.js app using gRPC-Web client
:3000] end subgraph "API Layer" Gateway[gRPC-Web Gateway
:8080
WebSocket Support] end subgraph "Microservices" UserSvc[User Service
:50051
gRPC] NoteSvc[Note Service
:50052
gRPC] end VueApp <-->|gRPC-Web
WebSocket| Gateway Gateway <-->|gRPC| UserSvc Gateway <-->|gRPC| NoteSvc NoteSvc -->|gRPC Call| UserSvc style Gateway fill:#4CAF50 style UserSvc fill:#2196F3 style NoteSvc fill:#FF9800 style VueApp fill:#42b883
Communication Flow
Step 1: Defining Protocol Buffers
Protocol Buffers are the foundation of gRPC. They define your API contract and data structures.
User Service Proto
syntax = "proto3";
package user;
option go_package = "github.com/colossus21/alamrafiul/projects/grpc-notes-app/proto/user";
service UserService {
rpc CreateUser(CreateUserRequest) returns (User);
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc UpdateUser(UpdateUserRequest) returns (User);
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
}
message User {
string id = 1;
string username = 2;
string email = 3;
int64 created_at = 4;
int64 updated_at = 5;
}
message CreateUserRequest {
string username = 1;
string email = 2;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page = 1;
int32 page_size = 2;
}
message ListUsersResponse {
repeated User users = 1;
int32 total = 2;
}
Note Service Proto
syntax = "proto3";
package note;
option go_package = "github.com/colossus21/alamrafiul/projects/grpc-notes-app/proto/note";
service NoteService {
rpc CreateNote(CreateNoteRequest) returns (Note);
rpc GetNote(GetNoteRequest) returns (Note);
rpc ListNotes(ListNotesRequest) returns (ListNotesResponse);
rpc ListNotesByUser(ListNotesByUserRequest) returns (ListNotesResponse);
rpc UpdateNote(UpdateNoteRequest) returns (Note);
rpc DeleteNote(DeleteNoteRequest) returns (DeleteNoteResponse);
rpc ShareNote(ShareNoteRequest) returns (ShareNoteResponse);
}
message Note {
string id = 1;
string title = 2;
string content = 3;
string user_id = 4;
UserInfo user = 5; // Enriched from User Service
repeated string shared_with_user_ids = 6;
repeated UserInfo shared_with_users = 7; // Enriched from User Service
int64 created_at = 8;
int64 updated_at = 9;
}
message UserInfo {
string id = 1;
string username = 2;
string email = 3;
}
Key Design Decision: The Note message includes both user IDs and enriched UserInfo. The Note Service fetches user details from the User Service to provide complete information in responses.
Step 2: Implementing the User Service
The User Service is a straightforward CRUD service using in-memory storage.
type UserServiceServer struct {
pb.UnimplementedUserServiceServer
users map[string]*pb.User
mu sync.RWMutex
}
func (s *UserServiceServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
log.Printf("CreateUser called: username=%s, email=%s", req.Username, req.Email)
if req.Username == "" || req.Email == "" {
return nil, status.Error(codes.InvalidArgument, "username and email are required")
}
s.mu.Lock()
defer s.mu.Unlock()
// Check for duplicates
for _, user := range s.users {
if user.Username == req.Username {
return nil, status.Error(codes.AlreadyExists, "username already exists")
}
if user.Email == req.Email {
return nil, status.Error(codes.AlreadyExists, "email already exists")
}
}
now := time.Now().Unix()
user := &pb.User{
Id: uuid.New().String(),
Username: req.Username,
Email: req.Email,
CreatedAt: now,
UpdatedAt: now,
}
s.users[user.Id] = user
log.Printf("User created: id=%s, username=%s", user.Id, user.Username)
return user, nil
}
func (s *UserServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, exists := s.users[req.Id]
if !exists {
return nil, status.Error(codes.NotFound, "user not found")
}
return user, nil
}
Starting the User Service
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "50051"
}
lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
userService := NewUserServiceServer()
pb.RegisterUserServiceServer(grpcServer, userService)
// Enable reflection for grpcurl testing
reflection.Register(grpcServer)
log.Printf("User Service starting on port %s", port)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Step 3: Implementing the Note Service
The Note Service demonstrates inter-service communication by calling the User Service.
Connecting to User Service
type NoteServiceServer struct {
notepb.UnimplementedNoteServiceServer
notes map[string]*notepb.Note
mu sync.RWMutex
userClient userpb.UserServiceClient // gRPC client for User Service
}
func main() {
userServiceAddr := os.Getenv("USER_SERVICE_ADDR")
if userServiceAddr == "" {
userServiceAddr = "localhost:50051"
}
// Connect to User Service via gRPC
log.Printf("Connecting to User Service at %s", userServiceAddr)
userConn, err := grpc.Dial(
userServiceAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("failed to connect to user service: %v", err)
}
defer userConn.Close()
userClient := userpb.NewUserServiceClient(userConn)
noteService := NewNoteServiceServer(userClient)
// ... rest of setup
}
Making RPC Calls to User Service
// getUserInfo fetches user information from the User Service
func (s *NoteServiceServer) getUserInfo(ctx context.Context, userID string) (*notepb.UserInfo, error) {
user, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: userID})
if err != nil {
return nil, err
}
return ¬epb.UserInfo{
Id: user.Id,
Username: user.Username,
Email: user.Email,
}, nil
}
// enrichNoteWithUserInfo adds user information to a note
func (s *NoteServiceServer) enrichNoteWithUserInfo(ctx context.Context, note *notepb.Note) error {
// Get owner info
userInfo, err := s.getUserInfo(ctx, note.UserId)
if err != nil {
log.Printf("Warning: failed to get user info for user_id=%s: %v", note.UserId, err)
} else {
note.User = userInfo
}
// Get shared users info
if len(note.SharedWithUserIds) > 0 {
sharedUsers := make([]*notepb.UserInfo, 0, len(note.SharedWithUserIds))
for _, userID := range note.SharedWithUserIds {
userInfo, err := s.getUserInfo(ctx, userID)
if err != nil {
log.Printf("Warning: failed to get user info for shared user_id=%s: %v", userID, err)
continue
}
sharedUsers = append(sharedUsers, userInfo)
}
note.SharedWithUsers = sharedUsers
}
return nil
}
Creating Notes with User Validation
func (s *NoteServiceServer) CreateNote(ctx context.Context, req *notepb.CreateNoteRequest) (*notepb.Note, error) {
log.Printf("CreateNote called: title=%s, user_id=%s", req.Title, req.UserId)
// Verify user exists by calling User Service
_, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: req.UserId})
if err != nil {
return nil, status.Error(codes.NotFound, "user not found")
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().Unix()
note := ¬epb.Note{
Id: uuid.New().String(),
Title: req.Title,
Content: req.Content,
UserId: req.UserId,
SharedWithUserIds: []string{},
CreatedAt: now,
UpdatedAt: now,
}
s.notes[note.Id] = note
// Enrich with user info before returning
if err := s.enrichNoteWithUserInfo(ctx, note); err != nil {
log.Printf("Warning: failed to enrich note with user info: %v", err)
}
log.Printf("Note created: id=%s, title=%s", note.Id, note.Title)
return note, nil
}
Sharing Notes
func (s *NoteServiceServer) ShareNote(ctx context.Context, req *notepb.ShareNoteRequest) (*notepb.ShareNoteResponse, error) {
log.Printf("ShareNote called: note_id=%s, user_id=%s", req.NoteId, req.UserId)
// Verify user exists before sharing
_, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: req.UserId})
if err != nil {
return nil, status.Error(codes.NotFound, "user not found")
}
s.mu.Lock()
defer s.mu.Unlock()
note, exists := s.notes[req.NoteId]
if !exists {
return nil, status.Error(codes.NotFound, "note not found")
}
// Check if already shared
for _, sharedUserID := range note.SharedWithUserIds {
if sharedUserID == req.UserId {
return nil, status.Error(codes.AlreadyExists, "note already shared with this user")
}
}
note.SharedWithUserIds = append(note.SharedWithUserIds, req.UserId)
note.UpdatedAt = time.Now().Unix()
// Make a copy and enrich
noteCopy := ¬epb.Note{
Id: note.Id,
Title: note.Title,
Content: note.Content,
UserId: note.UserId,
SharedWithUserIds: note.SharedWithUserIds,
CreatedAt: note.CreatedAt,
UpdatedAt: note.UpdatedAt,
}
if err := s.enrichNoteWithUserInfo(ctx, noteCopy); err != nil {
log.Printf("Warning: failed to enrich note with user info: %v", err)
}
return ¬epb.ShareNoteResponse{Success: true, Note: noteCopy}, nil
}
Step 4: Building the API Gateway
Browsers can’t make native gRPC calls because they don’t support HTTP/2 trailers. The gateway solves this by translating between gRPC-Web and gRPC.
Setting Up grpc-web
import (
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/rs/cors"
)
func main() {
// Connect to backend services
userConn, _ := grpc.Dial(userServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
noteConn, _ := grpc.Dial(noteServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
// Create a gRPC server
grpcServer := grpc.NewServer()
// Register proxy handlers
userpb.RegisterUserServiceServer(grpcServer, &userServiceProxy{
client: userpb.NewUserServiceClient(userConn),
})
notepb.RegisterNoteServiceServer(grpcServer, ¬eServiceProxy{
client: notepb.NewNoteServiceClient(noteConn),
})
// Wrap with grpc-web
wrappedGrpc := grpcweb.WrapServer(grpcServer,
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
grpcweb.WithWebsockets(true),
grpcweb.WithWebsocketOriginFunc(func(req *http.Request) bool {
return true // Allow all origins for development
}),
)
// ... serve HTTP
}
Proxy Implementation
The proxy simply forwards requests to the actual services:
type userServiceProxy struct {
userpb.UnimplementedUserServiceServer
client userpb.UserServiceClient
}
func (p *userServiceProxy) CreateUser(ctx context.Context, req *userpb.CreateUserRequest) (*userpb.User, error) {
return p.client.CreateUser(ctx, req)
}
func (p *userServiceProxy) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*userpb.User, error) {
return p.client.GetUser(ctx, req)
}
// ... other methods
HTTP Handler with CORS
handler := http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if wrappedGrpc.IsGrpcWebRequest(req) ||
wrappedGrpc.IsAcceptableGrpcCorsRequest(req) ||
wrappedGrpc.IsGrpcWebSocketRequest(req) {
wrappedGrpc.ServeHTTP(resp, req)
} else {
if req.URL.Path == "/health" {
resp.WriteHeader(http.StatusOK)
resp.Write([]byte("OK"))
return
}
http.NotFound(resp, req)
}
})
// Add CORS
corsHandler := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"*"},
AllowCredentials: true,
}).Handler(handler)
httpServer := &http.Server{
Addr: ":8080",
Handler: corsHandler,
}
log.Printf("Gateway starting on port 8080")
httpServer.ListenAndServe()
Step 5: Vue.js Frontend with gRPC-Web
Simplified gRPC Client
For this demo, we use a simplified JSON-based approach. In production, you’d use the official gRPC-Web client:
class GrpcClient {
constructor(baseUrl = 'http://localhost:8080') {
this.baseUrl = baseUrl;
}
async callUnary(service, method, request) {
const url = `${this.baseUrl}/${service}/${method}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/grpc-web+json',
'X-Grpc-Web': '1',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return await response.json();
}
}
export const grpcClient = new GrpcClient();
User Service Client
export const userService = {
async createUser(username, email) {
return grpcClient.callUnary('user.UserService', 'CreateUser', {
username,
email,
});
},
async getUser(id) {
return grpcClient.callUnary('user.UserService', 'GetUser', {
id,
});
},
async listUsers(page = 1, pageSize = 10) {
return grpcClient.callUnary('user.UserService', 'ListUsers', {
page,
pageSize,
});
},
async deleteUser(id) {
return grpcClient.callUnary('user.UserService', 'DeleteUser', {
id,
});
},
};
Note Service Client
export const noteService = {
async createNote(title, content, userId) {
return grpcClient.callUnary('note.NoteService', 'CreateNote', {
title,
content,
userId,
});
},
async listNotesByUser(userId, page = 1, pageSize = 10) {
return grpcClient.callUnary('note.NoteService', 'ListNotesByUser', {
userId,
page,
pageSize,
});
},
async shareNote(noteId, userId) {
return grpcClient.callUnary('note.NoteService', 'ShareNote', {
noteId,
userId,
});
},
};
Vue Component Example
<script setup>
import { ref, onMounted } from 'vue';
import { userService } from '../services/grpc-client';
const users = ref([]);
const loading = ref(false);
const loadUsers = async () => {
loading.value = true;
try {
const response = await userService.listUsers();
users.value = response.users || [];
} catch (err) {
console.error('Error loading users:', err);
} finally {
loading.value = false;
}
};
const createUser = async (username, email) => {
try {
await userService.createUser(username, email);
await loadUsers();
} catch (err) {
alert('Error creating user: ' + err.message);
}
};
onMounted(loadUsers);
</script>
Step 6: Docker Deployment
Docker Compose Configuration
version: '3.8'
services:
user-service:
build:
context: .
dockerfile: services/user-service/Dockerfile
ports:
- "50051:50051"
environment:
- PORT=50051
networks:
- grpc-network
note-service:
build:
context: .
dockerfile: services/note-service/Dockerfile
ports:
- "50052:50052"
environment:
- PORT=50052
- USER_SERVICE_ADDR=user-service:50051
depends_on:
- user-service
networks:
- grpc-network
gateway:
build:
context: .
dockerfile: gateway/Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
- USER_SERVICE_ADDR=user-service:50051
- NOTE_SERVICE_ADDR=note-service:50052
depends_on:
- user-service
- note-service
networks:
- grpc-network
frontend:
build:
context: ./frontend
ports:
- "3000:3000"
depends_on:
- gateway
networks:
- grpc-network
networks:
grpc-network:
driver: bridge
Multi-Stage Dockerfile for Go Services
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install protoc
RUN apk add --no-cache protobuf-dev git
# Copy go mod files
COPY services/user-service/go.mod services/user-service/go.sum ./
RUN go mod download
# Install protoc plugins
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# Generate protobuf code
COPY proto /proto
RUN mkdir -p pb && \
protoc --go_out=pb --go_opt=paths=source_relative \
--go-grpc_out=pb --go-grpc_opt=paths=source_relative \
/proto/user.proto
# Build
COPY services/user-service/*.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o user-service
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/user-service .
EXPOSE 50051
CMD ["./user-service"]
Testing with grpcurl
grpcurl is like curl for gRPC services:
# List available services
grpcurl -plaintext localhost:50051 list
# Create a user
grpcurl -plaintext -d '{
"username": "alice",
"email": "[email protected]"
}' localhost:50051 user.UserService/CreateUser
# Response:
{
"id": "3f2a8b4c-...",
"username": "alice",
"email": "[email protected]",
"createdAt": "1706400000",
"updatedAt": "1706400000"
}
# Create a note
grpcurl -plaintext -d '{
"title": "My First Note",
"content": "Hello gRPC!",
"userId": "3f2a8b4c-..."
}' localhost:50052 note.NoteService/CreateNote
# List notes by user
grpcurl -plaintext -d '{
"userId": "3f2a8b4c-...",
"page": 1,
"pageSize": 10
}' localhost:50052 note.NoteService/ListNotesByUser
gRPC vs Event-Driven: When to Use What?
Use gRPC When:
- Strong Typing Matters: You want compile-time type safety
- Low Latency Required: Binary serialization is faster
- Request/Reply Pattern: You need synchronous responses
- Internal Services: Microservices within your data center
- Streaming: You need bidirectional streaming support
Use Event-Driven (NATS) When:
- Loose Coupling: Services shouldn’t know about each other
- Async Operations: You don’t need immediate responses
- Broadcasting: Multiple services need the same data
- Resilience: Failures shouldn’t cascade
- Event Sourcing: You need an audit trail
Architecture Comparison
| Aspect | gRPC | NATS Event-Driven |
|---|---|---|
| Communication | Direct RPC | Publish/Subscribe |
| Coupling | Tighter | Looser |
| Response Time | Synchronous | Asynchronous |
| Type Safety | Strong (protobuf) | Flexible (JSON) |
| Service Discovery | Address-based | Topic-based |
| Failure Handling | Immediate error | Retry/DLQ |
| Use Case | Request/Reply | Fire-and-forget |
| Complexity | Lower initial | Higher initial |
| Scalability | Vertical | Horizontal |
Key Takeaways
- Protocol Buffers provide strong typing and efficient serialization
- Service-to-Service gRPC enables direct, type-safe communication
- gRPC-Web Gateway bridges the gap between browsers and gRPC
- WebSocket Support enables real-time updates in browsers
- Inter-Service Calls demonstrate how microservices collaborate
- Docker Compose simplifies multi-service orchestration
Performance Considerations
gRPC Advantages:
- Binary Protocol: 30-50% smaller payloads than JSON
- HTTP/2: Connection multiplexing reduces latency
- Streaming: Efficient for large data transfers
- Code Generation: No runtime reflection overhead
Watch Out For:
- Browser Compatibility: Requires grpc-web proxy
- Debugging: Binary format harder to inspect than JSON
- Load Balancing: HTTP/2 requires special handling
- Error Handling: gRPC status codes differ from HTTP
Next Steps
To enhance this application:
- Add Authentication: Implement JWT tokens in gateway
- Database Integration: Replace in-memory storage with PostgreSQL
- Streaming: Add server streaming for real-time updates
- Error Handling: Implement retry logic and circuit breakers
- Monitoring: Add Prometheus metrics and distributed tracing
- TLS: Enable encryption for production
- Load Balancing: Implement client-side load balancing
Source Code
The complete source code is available in the projects/grpc-notes-app directory, including:
- Protocol Buffer definitions
- Go microservices with full CRUD operations
- gRPC-Web gateway with WebSocket support
- Vue.js frontend with Tailwind CSS
- Docker Compose orchestration
- Makefiles for code generation
Conclusion
gRPC provides a powerful foundation for microservices architecture. While it requires more upfront setup than REST, the benefits of strong typing, performance, and built-in streaming make it ideal for service-to-service communication. The WebSocket gateway pattern bridges the gap to browser clients, giving you the best of both worlds.
Whether you choose gRPC, event-driven architecture, or a hybrid approach depends on your specific requirements. This guide demonstrates that with the right architecture, you can build efficient, type-safe microservices that scale.
Happy coding!