Implementing User Service with gRPC and PostgreSQL - Part 3

Lakhan SamaniLakhan Samani
7 min read

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.

0
Subscribe to my newsletter

Read articles from Lakhan Samani directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lakhan Samani
Lakhan Samani

I’m Lakhan Samani from India 🇮🇳. I’m the creator and maintainer of authorizer.dev | Freelance Software Engineer | Prev: Cloud Software Engineer at ArangoDB. I am passionate about building products and developer tools. This website is my internet space, where I write about Building Software | Open Source | Finance and Life.