Implementing User Service with gRPC and PostgreSQL - Part 3

In Part 2 we introduced apis for User service, this post walks through implementing a User Service in Go using gRPC with PostgreSQL as the database. The service provides user authentication with JWT-based authentication. The implementation includes:

  • gRPC service definition

  • Database integration using GORM

  • JWT-based authentication

  • A structured service layer

Note: Please replace the github user name and repo in each of the following folder

Project Structure

The folder structure for the service is as follows:

ecom-grpc/userd/
│-- db/
│   │-- db.go
│   │-- user.go
│-- service/
│   │-- service.go
│   │-- login.go
│   │-- register.go
│   │-- me.go
│-- utils/
│   │-- jwt.go
│-- main.go
│-- .env
│-- Dockerfile
│-- .dockerignore

Database Provider (db/db.go)

This file defines the interface for database operations and initializes the connection to PostgreSQL using GORM.

package db

import (
    "log"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

// Provider defines the interface for the database provider
type Provider interface {
    CreateUser(user *User) (*User, error)
    GetUserByEmail(email string) (*User, error)
    GetUserByID(id string) (*User, error)
}

// provider implements the Provider interface
type provider struct {
    db *gorm.DB
}

// New creates new database provider
// connects to db and returns the provider
func New(dbURL string) Provider {
    db, err := gorm.Open(postgres.Open(dbURL), &gorm.Config{})
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }

    // Auto-migrate User model
    db.AutoMigrate(&User{})

    return &provider{db}
}

Database User Methods (db/user.go)

This file implements the methods to interact with the users table.

package db

import (
    "github.com/google/uuid"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"

    user "github.com/lakhansamani/ecom-grpc-apis/user/v1"
)

// User represents the User model in DB
type User struct {
    ID       string `gorm:"primaryKey"`
    Name     string
    Email    string `gorm:"unique"`
    Password string
}

// AsAPIUser converts the User model to API User
func (u *User) AsAPIUser() *user.User {
    return &user.User{
        Id:    u.ID,
        Name:  u.Name,
        Email: u.Email,
    }
}

// BeforeSave GORM hook to hash password only if it's changed
func (u *User) BeforeSave(tx *gorm.DB) (err error) {
    // Hash the new password
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    u.Password = string(hashedPassword)
    return nil
}

// Before create
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
    // Generate UUID
    u.ID = uuid.NewString()
    return
}

// CreateUser creates a new user in the database
func (p *provider) CreateUser(u *User) (*User, error) {
    err := p.db.Create(u).Error
    return u, err
}

// GetUserByEmail fetches a user by email from the database
func (p *provider) GetUserByEmail(email string) (*User, error) {
    var u User
    err := p.db.Where("email = ?", email).First(&u).Error
    return &u, err
}

// GetUserByID fetches a user by ID from the database
func (p *provider) GetUserByID(id string) (*User, error) {
    var u User
    err := p.db.Where("id = ?", id).First(&u).Error
    return &u, err
}

User Service (service/service.go)

This file defines the service layer for user operations. It holds configurations and dependencies.

package service

import (
    user "github.com/lakhansamani/ecom-grpc-apis/user/v1"
    "github.com/lakhansamani/ecom-grpc-userd/db"
)

type Config struct {
    JWTSecret string
}

type Dependencies struct {
    DBProvider db.Provider
}

// Service implements the User service.
type Service interface {
    user.UserServiceServer
}

type service struct {
    Config
    Dependencies
}

// New creates a new User service.
func New(cfg Config, deps Dependencies) Service {
    return &service{
        Config:       cfg,
        Dependencies: deps,
    }
}

Register API (service/register.go)

Handles user registration.

package service

import (
    "context"
    "errors"
    "strings"

    user "github.com/lakhansamani/ecom-grpc-apis/user/v1"

    "github.com/lakhansamani/ecom-grpc-userd/db"
)

// Register API to register a new user
// Permission: none
func (s *service) Register(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error) {
    name := req.GetName()
    email := req.GetEmail()
    password := req.GetPassword()

    if strings.TrimSpace(name) == "" {
        return nil, errors.New("name is required")
    }

    if strings.TrimSpace(email) == "" {
        return nil, errors.New("email is required")
    }

    if strings.TrimSpace(password) == "" {
        return nil, errors.New("password is required")
    }

    resUser, err := s.DBProvider.CreateUser(&db.User{
        Name:     name,
        Email:    email,
        Password: password,
    })
    if err != nil {
        return nil, err
    }

    return &user.RegisterResponse{
        UserId: resUser.ID,
    }, nil
}

Login API (service/login.go)

Handles user login and JWT generation.

package service

import (
    "context"
    "errors"
    "strings"

    "golang.org/x/crypto/bcrypt"

    user "github.com/lakhansamani/ecom-grpc-apis/user/v1"

    "github.com/lakhansamani/ecom-grpc-userd/utils"
)

// Login API to login a user
// Permission: none
func (s *service) Login(ctx context.Context, req *user.LoginRequest) (*user.LoginResponse, error) {
    email := req.GetEmail()
    password := req.GetPassword()

    if strings.TrimSpace(email) == "" {
        return nil, errors.New("email is required")
    }

    if strings.TrimSpace(password) == "" {
        return nil, errors.New("password is required")
    }

    // Get user by email
    resUser, err := s.DBProvider.GetUserByEmail(email)
    if err != nil {
        return nil, err
    }

    // Match password
    if err := bcrypt.CompareHashAndPassword([]byte(resUser.Password), []byte(password)); err != nil {
        return nil, errors.New("invalid password")
    }

    // Generate JWT token
    token, err := utils.GenerateJWT(s.JWTSecret, resUser.ID)
    if err != nil {
        return nil, err
    }

    return &user.LoginResponse{
        Token: token,
    }, nil
}

Me API (service/me.go)

Retrieves the currently authenticated user.

package service

import (
    "context"
    "errors"
    "strings"

    user "github.com/lakhansamani/ecom-grpc-apis/user/v1"
    "github.com/lakhansamani/ecom-grpc-userd/utils"
    "google.golang.org/grpc/metadata"
)

// Me API to get user details
// Permission: authenticated user
func (s *service) Me(ctx context.Context, req *user.MeRequest) (*user.MeResponse, error) {
    // Get the Authorization bearer token from the context
    // Extract the token from the header
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, errors.New("missing metadata")
    }

    authHeader, exists := md["authorization"]
    if !exists || len(authHeader) == 0 {
        return nil, errors.New("missing authorization token")
    }

    token := authHeader[0]
    // Make sure the token is not empty and is bearer token
    if token == "" {
        return nil, errors.New("missing token")
    }
    tokenSplit := strings.Split(token, " ")
    if len(tokenSplit) != 2 {
        return nil, errors.New("invalid token")
    }
    if strings.ToLower(tokenSplit[0]) != "bearer" {
        return nil, errors.New("invalid token")
    }
    token = tokenSplit[1]
    userID, err := utils.VerifyJWT(s.JWTSecret, token)
    if err != nil {
        return nil, err
    }
    // Fetch the user from the database
    resUser, err := s.DBProvider.GetUserByID(userID)
    if err != nil {
        return nil, err
    }
    // Return the user details
    return &user.MeResponse{
        User: resUser.AsAPIUser(),
    }, nil
}

JWT Utility (utils/jwt.go)

Handles JWT generation and verification.

package utils

import (
    "time"

    "github.com/golang-jwt/jwt"
)

// Generate JWT token
func GenerateJWT(secret, userID string) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "user_id": userID,
        "exp":     time.Now().Add(time.Hour * 24).Unix(),
    })
    return token.SignedString([]byte(secret))
}

// VerifyJWT verifies the JWT token
func VerifyJWT(secret, tokenString string) (string, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        return []byte(secret), nil
    })
    if err != nil {
        return "", err
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return "", err
    }
    // Check if token is valid
    if !token.Valid {
        return "", err
    }
    return claims["user_id"].(string), nil
}

Main File (main.go)

Starts the gRPC server and initializes dependencies.

package main

import (
    "log"
    "net"
    "os"

    "github.com/joho/godotenv"
    "google.golang.org/grpc"

    userpb "github.com/lakhansamani/ecom-grpc-apis/user/v1"

    "github.com/lakhansamani/ecom-grpc-userd/db"
    "github.com/lakhansamani/ecom-grpc-userd/service"
)

func main() {
    // Read .env file as environment variables
    err := godotenv.Load()
    if err != nil {
        log.Println(".env file not found, using environment variables")
    }

    // DB URL
    dbURL := os.Getenv("DB_URL")
    if dbURL == "" {
        log.Fatal("DB_URL is required")
    }
    // JWT Secret
    jwtSecret := os.Getenv("JWT_SECRET")
    if jwtSecret == "" {
        log.Fatal("JWT_SECRET is required")
    }
    // Initialize database
    dbProvider := db.New(dbURL)

    // Create a new gRPC server
    server := grpc.NewServer()

    // Register UserService with gRPC
    userService := service.New(
        service.Config{
            JWTSecret: jwtSecret,
        },
        service.Dependencies{
            DBProvider: dbProvider,
        })
    userpb.RegisterUserServiceServer(server, userService)

    // Start gRPC server
    listener, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }
    log.Println("gRPC Server is running on port 50051...")
    if err := server.Serve(listener); err != nil {
        log.Fatalf("Failed to serve: %v", err)
    }
}

Dockerfile (Dockerfile)

This file helps in creating userd container image that we can use in future with kubernetes.

# Build Stage
FROM golang:1.23 AS builder

WORKDIR /app

# Copy go.mod and go.sum and download dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build the Go binary with static linking (Alpine compatible)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o userd ./main.go

# Final Runtime Stage (Alpine)
FROM alpine:latest

WORKDIR /app

# Install certificates (required for HTTPS calls)
RUN apk add --no-cache ca-certificates

# Copy binary from builder
COPY --from=builder /app/userd .

# Expose gRPC port
EXPOSE 50051

# Run the application
CMD ["./userd"]

Docker ignore file (.dockerignore)

This file helps ignoring development files example .env in production build

.env

Here is .env file for local development

DB_URL=postgres://postgres:postgres@localhost:5432/userdb
JWT_SECRET=secret

Running the Service


docker run --name postgres-cluster -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
docker exec -it postgres-cluster psql -U postgres -c "CREATE DATABASE userdb;"
go run main.go

Now, your User Service is live with gRPC, PostgreSQL, and JWT authentication! 🚀

You can try following commands with correct .proto file path

grpcurl -plaintext -d '{ "name": "John Doe", "email": "john@example.com", "password": "securepass" }' -proto=apis/user/v1/user.proto localhost:50051 user.v1.UserService/Register

grpcurl -plaintext -d '{ "email": "john@example.com", "password": "securepass" }' -proto=apis/user/v1/user.proto localhost:50051 user.v1.UserService/Login

grpcurl -plaintext -H "authorization: bearer JWT_TOKEN" -proto=apis/user/v1/user.proto localhost:50051 user.v1.UserService/Me

🎯 Next Steps

  • 🚀 Now that we have the User Service implemented, in Part 4, we will:

  • ✅ Implement Order Service.