• Software Letters
  • Posts
  • Building and Enhancing a Job Management Microservice with Go, Gin, and PostgreSQL

Building and Enhancing a Job Management Microservice with Go, Gin, and PostgreSQL

Best Practices for Creating Scalable, Maintainable, and High-Performance Microservices

Building a Job Management System with Go, Gin, and PostgreSQL

Introduction

In the rapidly evolving tech landscape, microservices have emerged as a preferred architectural style for designing scalable and maintainable applications. In this post, we will delve into building a microservice for managing job offers, job applications, and job alerts using Go, Gin, and PostgreSQL. We'll explore setting up a basic microservice, defining models, handling routing, and integrating with PostgreSQL for data persistence.

Prerequisites

Before we begin, ensure you have the following installed:

Project Structure

Let's start by organizing our project directory:

job-management/
├── main.go
├── models/
│   ├── jobOffer.go
│   ├── jobApplication.go
│   └── jobAlert.go
├── controllers/
│   ├── jobOfferController.go
│   ├── jobApplicationController.go
│   └── jobAlertController.go
├── routes/
│   └── routes.go
├── config/
│   └── config.go
└── go.mod

Setting Up the Project

Initialize your Go project and install the necessary packages:

go mod init job-management 
go get github.com/gin-gonic/gin 
go get github.com/jinzhu/gorm 
go get github.com/jinzhu/gorm/dialects/postgres

Database Configuration

Create a configuration file to manage database connection settings.

// config/config.go
package config

import (
	"fmt"
	"os"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"
)

var DB *gorm.DB

func ConnectDatabase() {
	var err error
	DB, err = gorm.Open("postgres", fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable password=%s",
		os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_NAME"), os.Getenv("DB_PASSWORD")))
	if err != nil {
		panic("Failed to connect to database!")
	}
}

Ensure your environment variables are set appropriately for your database.

Defining Models

Define the data models for job offers, job applications, and job alerts.

// models/jobOffer.go
package models

import (
	"github.com/jinzhu/gorm"
)

type JobOffer struct {
	gorm.Model
	Title       string
	Description string
	Company     string
	Location    string
	Salary      string
}

// models/jobApplication.go
package models

import (
	"github.com/jinzhu/gorm"
)

type JobApplication struct {
	gorm.Model
	JobOfferID uint
	ApplicantName  string
	ApplicantEmail string
	ResumeURL      string
	Status         string
}

// models/jobAlert.go
package models

import (
	"github.com/jinzhu/gorm"
)

type JobAlert struct {
	gorm.Model
	UserID       uint
	Keyword      string
	Location     string
	Notification bool
}

Setting Up Controllers

Create controllers to handle HTTP requests for each model.

JobOffer Controller :

// controllers/jobOfferController.go
package controllers

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"job-management/models"
	"job-management/config"
)

func CreateJobOffer(c *gin.Context) {
	var jobOffer models.JobOffer
	if err := c.ShouldBindJSON(&jobOffer); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	config.DB.Create(&jobOffer)
	c.JSON(http.StatusOK, jobOffer)
}

func GetJobOffers(c *gin.Context) {
	var jobOffers []models.JobOffer
	config.DB.Find(&jobOffers)
	c.JSON(http.StatusOK, jobOffers)
}

JobApplication Controller

// controllers/jobApplicationController.go
package controllers

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"job-management/models"
	"job-management/config"
)

func CreateJobApplication(c *gin.Context) {
	var jobApplication models.JobApplication
	if err := c.ShouldBindJSON(&jobApplication); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	config.DB.Create(&jobApplication)
	c.JSON(http.StatusOK, jobApplication)
}

func GetJobApplications(c *gin.Context) {
	var jobApplications []models.JobApplication
	config.DB.Find(&jobApplications)
	c.JSON(http.StatusOK, jobApplications)
}

func GetJobApplicationByID(c *gin.Context) {
	id := c.Param("id")
	var jobApplication models.JobApplication
	if err := config.DB.First(&jobApplication, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job application not found"})
		return
	}
	c.JSON(http.StatusOK, jobApplication)
}

func UpdateJobApplication(c *gin.Context) {
	id := c.Param("id")
	var jobApplication models.JobApplication
	if err := config.DB.First(&jobApplication, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job application not found"})
		return
	}
	if err := c.ShouldBindJSON(&jobApplication); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	config.DB.Save(&jobApplication)
	c.JSON(http.StatusOK, jobApplication)
}

func DeleteJobApplication(c *gin.Context) {
	id := c.Param("id")
	var jobApplication models.JobApplication
	if err := config.DB.First(&jobApplication, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job application not found"})
		return
	}
	config.DB.Delete(&jobApplication)
	c.JSON(http.StatusOK, gin.H{"message": "Job application deleted"})
}

JobAlert Controller

// controllers/jobAlertController.go
package controllers

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"job-management/models"
	"job-management/config"
)

func CreateJobAlert(c *gin.Context) {
	var jobAlert models.JobAlert
	if err := c.ShouldBindJSON(&jobAlert); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	config.DB.Create(&jobAlert)
	c.JSON(http.StatusOK, jobAlert)
}

func GetJobAlerts(c *gin.Context) {
	var jobAlerts []models.JobAlert
	config.DB.Find(&jobAlerts)
	c.JSON(http.StatusOK, jobAlerts)
}

func GetJobAlertByID(c *gin.Context) {
	id := c.Param("id")
	var jobAlert models.JobAlert
	if err := config.DB.First(&jobAlert, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job alert not found"})
		return
	}
	c.JSON(http.StatusOK, jobAlert)
}

func UpdateJobAlert(c *gin.Context) {
	id := c.Param("id")
	var jobAlert models.JobAlert
	if err := config.DB.First(&jobAlert, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job alert not found"})
		return
	}
	if err := c.ShouldBindJSON(&jobAlert); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	config.DB.Save(&jobAlert)
	c.JSON(http.StatusOK, jobAlert)
}

func DeleteJobAlert(c *gin.Context) {
	id := c.Param("id")
	var jobAlert models.JobAlert
	if err := config.DB.First(&jobAlert, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job alert not found"})
		return
	}
	config.DB.Delete(&jobAlert)
	c.JSON(http.StatusOK, gin.H{"message": "Job alert deleted"})
}

U

Defining Routes

Set up routing for the API endpoints.

// routes/routes.go
package routes

import (
	"github.com/gin-gonic/gin"
	"job-management/controllers"
)

func SetupRouter() *gin.Engine {
	r := gin.Default()
	v1 := r.Group("/api/v1")
	{
		v1.POST("/jobOffers", controllers.CreateJobOffer)
		v1.GET("/jobOffers", controllers.GetJobOffers)

		v1.POST("/jobApplications", controllers.CreateJobApplication)
		v1.GET("/jobApplications", controllers.GetJobApplications)
		v1.GET("/jobApplications/:id", controllers.GetJobApplicationByID)
		v1.PUT("/jobApplications/:id", controllers.UpdateJobApplication)
		v1.DELETE("/jobApplications/:id", controllers.DeleteJobApplication)

		v1.POST("/jobAlerts", controllers.CreateJobAlert)
		v1.GET("/jobAlerts", controllers.GetJobAlerts)
		v1.GET("/jobAlerts/:id", controllers.GetJobAlertByID)
		v1.PUT("/jobAlerts/:id", controllers.UpdateJobAlert)
		v1.DELETE("/jobAlerts/:id", controllers.DeleteJobAlert)
	}
	return r
}

Main Application Entry Point

Wire everything together in the main application file.

// main.go
package main

import (
	"job-management/config"
	"job-management/models"
	"job-management/routes"
	"github.com/gin-gonic/gin"
)

func main() {
	// Connect to the database
	config.ConnectDatabase()

	// Auto migrate database models
	config.DB.AutoMigrate(&models.JobOffer{}, &models.JobApplication{}, &models.JobAlert{})

	// Set up router
	r := routes.SetupRouter()

	// Start the server
	r.Run(":8080")
}

I've added the AutoMigrate function to automatically create the database tables for the JobOffer, JobApplication, and JobAlert models.

  1. Database Configuration: We set up the database configuration to connect to PostgreSQL.

  2. Models: We defined models for job offers, job applications, and job alerts.

  3. Controllers: We created controllers for handling CRUD operations for job applications and job alerts.

  4. Routes: We defined routes for the API endpoints.

  5. Main Application: We updated the main application entry point to initialize the database, set up routes, and start the server.

Running the Application

To run the application, use the following command:

go run main.go

Your microservice should now be running on http://localhost:8080.

Now, Organizing your Go Gin application into separate layers

Organizing your Go Gin application into separate layers can significantly improve the maintainability, testability, and scalability of your codebase. Separating concerns by having distinct packages for handlers, services, and database interactions (often referred to as repositories) can help you manage complexity as your application grows.

Here is a recommended structure for a Go Gin application:

job-management/
├── cmd/
│   └── server/
│       └── main.go
├── config/
│   └── config.go
├── internal/
│   ├── handlers/
│   │   ├── jobOfferHandler.go
│   │   ├── jobApplicationHandler.go
│   │   └── jobAlertHandler.go
│   ├── services/
│   │   ├── jobOfferService.go
│   │   ├── jobApplicationService.go
│   │   └── jobAlertService.go
│   ├── repositories/
│   │   ├── jobOfferRepository.go
│   │   ├── jobApplicationRepository.go
│   │   └── jobAlertRepository.go
│   ├── models/
│   │   ├── jobOffer.go
│   │   ├── jobApplication.go
│   │   └── jobAlert.go
│   └── routes/
│       └── routes.go
├── go.mod
└── go.sum

Explanation of Layers

  1. cmd/server: This package contains the main entry point of the application. It initializes the server and other components.

  2. config: This package contains configuration-related code, such as loading environment variables and setting up the database connection.

  3. internal/handlers: This package contains the HTTP handlers that handle incoming requests. Handlers should only be responsible for processing HTTP requests and responses.

  4. internal/services: This package contains the business logic of the application. Services should perform operations, interact with repositories, and contain the core business rules.

  5. internal/repositories: This package contains the database interaction code. Repositories should abstract the data access layer and provide methods for CRUD operations.

  6. internal/models: This package contains the data models representing the application's data structures.

  7. internal/routes: This package sets up the routing for the application.

Example Code

main.go

// cmd/server/main.go
package main

import (
	"job-management/config"
	"job-management/internal/routes"
	"github.com/gin-gonic/gin"
)

func main() {
	config.LoadConfig()
	config.ConnectDatabase()

	r := gin.Default()
	routes.SetupRouter(r)

	r.Run(":8080")
}

config.go

// config/config.go
package config

import (
	"fmt"
	"os"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"
)

var DB *gorm.DB

func LoadConfig() {
	// Load configuration from environment or file
}

func ConnectDatabase() {
	var err error
	DB, err = gorm.Open("postgres", fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable password=%s",
		os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_NAME"), os.Getenv("DB_PASSWORD")))
	if err != nil {
		panic("Failed to connect to database!")
	}
}

jobOffer.go

// internal/models/jobOffer.go
package models

import "github.com/jinzhu/gorm"

type JobOffer struct {
	gorm.Model
	Title       string
	Description string
	Company     string
	Location    string
	Salary      string
}

jobOfferRepository.go

// internal/repositories/jobOfferRepository.go
package repositories

import (
	"job-management/internal/models"
	"job-management/config"
)

type JobOfferRepository interface {
	Create(jobOffer *models.JobOffer) error
	FindAll() ([]models.JobOffer, error)
	FindByID(id uint) (*models.JobOffer, error)
	Update(jobOffer *models.JobOffer) error
	Delete(id uint) error
}

type jobOfferRepository struct {
	db *gorm.DB
}

func NewJobOfferRepository() JobOfferRepository {
	return &jobOfferRepository{db: config.DB}
}

func (r *jobOfferRepository) Create(jobOffer *models.JobOffer) error {
	return r.db.Create(jobOffer).Error
}

func (r *jobOfferRepository) FindAll() ([]models.JobOffer, error) {
	var jobOffers []models.JobOffer
	err := r.db.Find(&jobOffers).Error
	return jobOffers, err
}

func (r *jobOfferRepository) FindByID(id uint) (*models.JobOffer, error) {
	var jobOffer models.JobOffer
	err := r.db.First(&jobOffer, id).Error
	return &jobOffer, err
}

func (r *jobOfferRepository) Update(jobOffer *models.JobOffer) error {
	return r.db.Save(jobOffer).Error
}

func (r *jobOfferRepository) Delete(id uint) error {
	return r.db.Delete(&models.JobOffer{}, id).Error
}

jobOfferService.go

// internal/services/jobOfferService.go
package services

import (
	"job-management/internal/models"
	"job-management/internal/repositories"
)

type JobOfferService interface {
	CreateJobOffer(jobOffer *models.JobOffer) error
	GetAllJobOffers() ([]models.JobOffer, error)
	GetJobOfferByID(id uint) (*models.JobOffer, error)
	UpdateJobOffer(jobOffer *models.JobOffer) error
	DeleteJobOffer(id uint) error
}

type jobOfferService struct {
	repo repositories.JobOfferRepository
}

func NewJobOfferService(repo repositories.JobOfferRepository) JobOfferService {
	return &jobOfferService{repo: repo}
}

func (s *jobOfferService) CreateJobOffer(jobOffer *models.JobOffer) error {
	return s.repo.Create(jobOffer)
}

func (s *jobOfferService) GetAllJobOffers() ([]models.JobOffer, error) {
	return s.repo.FindAll()
}

func (s *jobOfferService) GetJobOfferByID(id uint) (*models.JobOffer, error) {
	return s.repo.FindByID(id)
}

func (s *jobOfferService) UpdateJobOffer(jobOffer *models.JobOffer) error {
	return s.repo.Update(jobOffer)
}

func (s *jobOfferService) DeleteJobOffer(id uint) error {
	return s.repo.Delete(id)
}

jobOfferHandler.go

// internal/handlers/jobOfferHandler.go
package handlers

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"job-management/internal/models"
	"job-management/internal/services"
)

type JobOfferHandler struct {
	service services.JobOfferService
}

func NewJobOfferHandler(service services.JobOfferService) *JobOfferHandler {
	return &JobOfferHandler{service: service}
}

func (h *JobOfferHandler) CreateJobOffer(c *gin.Context) {
	var jobOffer models.JobOffer
	if err := c.ShouldBindJSON(&jobOffer); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := h.service.CreateJobOffer(&jobOffer); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, jobOffer)
}

func (h *JobOfferHandler) GetAllJobOffers(c *gin.Context) {
	jobOffers, err := h.service.GetAllJobOffers()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, jobOffers)
}

func (h *JobOfferHandler) GetJobOfferByID(c *gin.Context) {
	id := c.Param("id")
	jobOffer, err := h.service.GetJobOfferByID(id)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job offer not found"})
		return
	}
	c.JSON(http.StatusOK, jobOffer)
}

func (h *JobOfferHandler) UpdateJobOffer(c *gin.Context) {
	id := c.Param("id")
	var jobOffer models.JobOffer
	if err := h.service.GetJobOfferByID(id); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job offer not found"})
		return
	}
	if err := c.ShouldBindJSON(&jobOffer); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := h.service.UpdateJobOffer(&jobOffer); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, jobOffer)
}

func (h *JobOfferHandler) DeleteJobOffer(c *gin.Context) {
	id := c.Param("id")
	if err := h.service.DeleteJobOffer(id); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Job offer not found"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"message": "Job offer deleted"})
}

routes.go

// internal/routes/routes.go
package routes

import (
	"github.com/gin-gonic/gin"
	"job-management/internal/handlers"
	"job-management/internal/repositories"
	"job-management/internal/services"
)

func SetupRouter(r *gin.Engine) {
	jobOfferRepo := repositories.NewJobOfferRepository()
	jobOfferService := services.NewJobOfferService(jobOfferRepo)
	jobOfferHandler := handlers.NewJobOfferHandler(jobOfferService)

	api := r.Group("/api/v1")
	{
		api.POST("/jobOffers", jobOfferHandler.CreateJobOffer)
		api.GET("/jobOffers", jobOfferHandler.GetAllJobOffers)
		api.GET("/jobOffers/:id", jobOfferHandler.GetJobOfferByID)
		api.PUT("/jobOffers/:id", jobOfferHandler.UpdateJobOffer)
		api.DELETE("/jobOffers/:id", jobOfferHandler.DeleteJobOffer)

		// Add routes for job applications and job alerts
	}
}

Benefits of This Organization

  • Separation of Concerns: Each layer has a distinct responsibility, making the code easier to understand and maintain.

  • Testability: Services and repositories can be easily unit tested in isolation.

  • Scalability: As the application grows, new features and changes can be added to specific layers without affecting others.

  • Maintainability: Well-organized code is easier to debug and extend.

By adopting this structure, you can create a more robust and maintainable Go Gin application.

Unit Testing

let's add unit tests for the jobOfferService and the jobOfferHandler. We will use the testing package provided by Go and the github.com/stretchr/testify package for assertions.

First, ensure you have the testify package installed:

go get github.com/stretchr/testify

Unit Testing the Job Offer Service

Create a new file jobOfferService_test.go in the services package:

// internal/services/jobOfferService_test.go
package services

import (
	"errors"
	"testing"
	"job-management/internal/models"
	"job-management/internal/repositories/mocks"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

func TestCreateJobOffer(t *testing.T) {
	mockRepo := new(mocks.JobOfferRepository)
	service := NewJobOfferService(mockRepo)

	jobOffer := &models.JobOffer{
		Title:       "Software Engineer",
		Description: "Develop and maintain software.",
		Company:     "Tech Corp",
		Location:    "Remote",
		Salary:      "100000",
	}

	mockRepo.On("Create", jobOffer).Return(nil)

	err := service.CreateJobOffer(jobOffer)

	assert.NoError(t, err)
	mockRepo.AssertExpectations(t)
}

func TestGetAllJobOffers(t *testing.T) {
	mockRepo := new(mocks.JobOfferRepository)
	service := NewJobOfferService(mockRepo)

	jobOffers := []models.JobOffer{
		{Title: "Software Engineer"},
		{Title: "Product Manager"},
	}

	mockRepo.On("FindAll").Return(jobOffers, nil)

	result, err := service.GetAllJobOffers()

	assert.NoError(t, err)
	assert.Equal(t, jobOffers, result)
	mockRepo.AssertExpectations(t)
}

func TestGetJobOfferByID(t *testing.T) {
	mockRepo := new(mocks.JobOfferRepository)
	service := NewJobOfferService(mockRepo)

	jobOffer := &models.JobOffer{Title: "Software Engineer"}
	mockRepo.On("FindByID", uint(1)).Return(jobOffer, nil)

	result, err := service.GetJobOfferByID(uint(1))

	assert.NoError(t, err)
	assert.Equal(t, jobOffer, result)
	mockRepo.AssertExpectations(t)
}

func TestUpdateJobOffer(t *testing.T) {
	mockRepo := new(mocks.JobOfferRepository)
	service := NewJobOfferService(mockRepo)

	jobOffer := &models.JobOffer{Title: "Software Engineer"}
	mockRepo.On("Update", jobOffer).Return(nil)

	err := service.UpdateJobOffer(jobOffer)

	assert.NoError(t, err)
	mockRepo.AssertExpectations(t)
}

func TestDeleteJobOffer(t *testing.T) {
	mockRepo := new(mocks.JobOfferRepository)
	service := NewJobOfferService(mockRepo)

	mockRepo.On("Delete", uint(1)).Return(nil)

	err := service.DeleteJobOffer(uint(1))

	assert.NoError(t, err)
	mockRepo.AssertExpectations(t)
}

Creating Mock Repository

Create a mock repository using mockery (you can install it with go get github.com/vektra/mockery/v2/.../):

mockery --name=JobOfferRepository --output=internal/repositories/mocks --outpkg=mocks

This command will generate a mock file JobOfferRepository.go in the internal/repositories/mocks package.

Unit Testing the Job Offer Handler

Create a new file jobOfferHandler_test.go in the handlers package:

// internal/handlers/jobOfferHandler_test.go
package handlers

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"job-management/internal/models"
	"job-management/internal/services/mocks"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

func TestCreateJobOffer(t *testing.T) {
	mockService := new(mocks.JobOfferService)
	handler := NewJobOfferHandler(mockService)

	jobOffer := models.JobOffer{
		Title:       "Software Engineer",
		Description: "Develop and maintain software.",
		Company:     "Tech Corp",
		Location:    "Remote",
		Salary:      "100000",
	}

	mockService.On("CreateJobOffer", mock.AnythingOfType("*models.JobOffer")).Return(nil)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)

	jobOfferJSON, _ := json.Marshal(jobOffer)
	c.Request, _ = http.NewRequest("POST", "/jobOffers", bytes.NewBuffer(jobOfferJSON))
	c.Request.Header.Set("Content-Type", "application/json")

	handler.CreateJobOffer(c)

	assert.Equal(t, http.StatusOK, w.Code)
	mockService.AssertExpectations(t)
}

func TestGetAllJobOffers(t *testing.T) {
	mockService := new(mocks.JobOfferService)
	handler := NewJobOfferHandler(mockService)

	jobOffers := []models.JobOffer{
		{Title: "Software Engineer"},
		{Title: "Product Manager"},
	}

	mockService.On("GetAllJobOffers").Return(jobOffers, nil)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)

	c.Request, _ = http.NewRequest("GET", "/jobOffers", nil)

	handler.GetAllJobOffers(c)

	assert.Equal(t, http.StatusOK, w.Code)
	var response []models.JobOffer
	json.Unmarshal(w.Body.Bytes(), &response)
	assert.Equal(t, jobOffers, response)
	mockService.AssertExpectations(t)
}

func TestGetJobOfferByID(t *testing.T) {
	mockService := new(mocks.JobOfferService)
	handler := NewJobOfferHandler(mockService)

	jobOffer := &models.JobOffer{Title: "Software Engineer"}
	mockService.On("GetJobOfferByID", uint(1)).Return(jobOffer, nil)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)

	c.Params = gin.Params{{Key: "id", Value: "1"}}
	c.Request, _ = http.NewRequest("GET", "/jobOffers/1", nil)

	handler.GetJobOfferByID(c)

	assert.Equal(t, http.StatusOK, w.Code)
	var response models.JobOffer
	json.Unmarshal(w.Body.Bytes(), &response)
	assert.Equal(t, jobOffer, &response)
	mockService.AssertExpectations(t)
}

func TestUpdateJobOffer(t *testing.T) {
	mockService := new(mocks.JobOfferService)
	handler := NewJobOfferHandler(mockService)

	jobOffer := models.JobOffer{Title: "Software Engineer"}
	mockService.On("GetJobOfferByID", uint(1)).Return(&jobOffer, nil)
	mockService.On("UpdateJobOffer", mock.AnythingOfType("*models.JobOffer")).Return(nil)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)

	jobOfferJSON, _ := json.Marshal(jobOffer)
	c.Params = gin.Params{{Key: "id", Value: "1"}}
	c.Request, _ = http.NewRequest("PUT", "/jobOffers/1", bytes.NewBuffer(jobOfferJSON))
	c.Request.Header.Set("Content-Type", "application/json")

	handler.UpdateJobOffer(c)

	assert.Equal(t, http.StatusOK, w.Code)
	mockService.AssertExpectations(t)
}

func TestDeleteJobOffer(t *testing.T) {
	mockService := new(mocks.JobOfferService)
	handler := NewJobOfferHandler(mockService)

	mockService.On("DeleteJobOffer", uint(1)).Return(nil)

	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)

	c.Params = gin.Params{{Key: "id", Value: "1"}}
	c.Request, _ = http.NewRequest("DELETE", "/jobOffers/1", nil)

	handler.DeleteJobOffer(c)

	assert.Equal(t, http.StatusOK, w.Code)
	mockService.AssertExpectations(t)
}

Creating Mock Service

Create a mock service using mockery:

mockery --name=JobOfferService --output=internal/services/mocks --outpkg=mocks

This command will generate a mock file JobOfferService.go in the internal/services/mocks package.

Running Tests

To run the tests, use the following command:

go test ./...

Summary

By organizing your code into layers and adding unit tests for each layer, you enhance the maintainability and testability of your application. This approach allows you to isolate and test individual components, ensuring they work as expected and making it easier to identify and fix issues.

Enhancing Your Go Gin Microservice: Best Practices for Scalability, Maintainability, and Performance

Improving your microservice can be approached from multiple perspectives, including architecture, development practices, performance optimization, and operational excellence. Here are some key areas to focus on:

1. Architecture

Microservices Design Patterns

  • Event-Driven Architecture: Implement event-driven communication between services to improve scalability and decoupling.

  • CQRS (Command Query Responsibility Segregation): Separate the read and write operations for better performance and scalability.

  • Saga Pattern: Manage complex transactions across multiple services to maintain data consistency.

API Gateway

  • Use an API Gateway to handle routing, composition, and protocol translation, and to centralize cross-cutting concerns like authentication and rate limiting.

Service Mesh

  • Implement a service mesh (e.g., Istio, Linkerd) to manage service-to-service communication, including load balancing, failure recovery, and observability.

2. Development Practices

Code Quality

  • Static Code Analysis: Use tools like golangci-lint to enforce coding standards and detect potential issues early.

  • Unit Testing and Integration Testing: Ensure comprehensive test coverage with unit tests for individual components and integration tests for end-to-end scenarios.

  • Code Reviews: Implement a robust code review process to maintain code quality and knowledge sharing.

Modular Code Organization

  • Separation of Concerns: Clearly separate the different layers (handlers, services, repositories) as you have done, and further refine them if necessary.

  • Reusable Packages: Create reusable packages or libraries for common functionalities (e.g., logging, error handling).

3. Performance Optimization

Database Optimization

  • Indexing: Ensure appropriate indexes on frequently queried fields.

  • Query Optimization: Optimize database queries to reduce latency and improve throughput.

  • Connection Pooling: Use connection pooling to manage database connections efficiently.

Caching

  • Implement caching strategies (e.g., in-memory caching with Redis) to reduce database load and improve response times for frequently accessed data.

Load Testing and Profiling

  • Conduct load testing using tools like k6 or Apache JMeter to identify bottlenecks.

  • Use profiling tools (e.g., pprof) to analyze CPU and memory usage and optimize performance-critical code paths.

4. Operational Excellence

Observability

  • Logging: Implement structured logging with context information to facilitate debugging and monitoring.

  • Monitoring and Alerting: Use monitoring tools (e.g., Prometheus, Grafana) to track system metrics and set up alerts for critical issues.

  • Tracing: Implement distributed tracing (e.g., with Jaeger or Zipkin) to trace requests across multiple services and identify performance bottlenecks.

CI/CD

  • Automated Testing: Integrate automated testing in the CI/CD pipeline to catch issues early.

  • Continuous Deployment: Automate the deployment process to ensure consistent and reliable releases.

  • Rollback Mechanisms: Implement rollback mechanisms to quickly revert to a stable state in case of deployment failures.

5. Security

Authentication and Authorization

  • Use industry-standard protocols (e.g., OAuth2, JWT) for authentication and authorization.

  • Implement role-based access control (RBAC) to manage permissions.

Data Protection

  • Encrypt sensitive data at rest and in transit.

  • Regularly audit and update dependencies to address security vulnerabilities.

6. Scalability and Reliability

Horizontal Scaling

  • Design services to be stateless where possible, enabling horizontal scaling by adding more instances.

Circuit Breaker Pattern

  • Implement circuit breakers (e.g., with Hystrix or Resilience4j) to handle failures gracefully and prevent cascading failures.

Load Balancing

  • Use load balancers to distribute incoming traffic evenly across service instances and improve availability.

7. Documentation

API Documentation

  • Use tools like Swagger or OpenAPI to generate interactive API documentation.

  • Maintain up-to-date documentation to ensure clarity for developers and consumers of your API.

Developer Documentation

  • Document architecture decisions, design patterns, and best practices to onboard new developers and maintain consistency.

Example Improvements

Implementing an API Gateway

// main.go
package main

import (
	"job-management/config"
	"job-management/internal/routes"
	"github.com/gin-gonic/gin"
	"github.com/krakendio/krakend-ce/config"
	"github.com/krakendio/krakend-ce/proxy"
	"github.com/krakendio/krakend-ce/router/gin"
)

func main() {
	config.LoadConfig()
	config.ConnectDatabase()

	r := gin.Default()
	routes.SetupRouter(r)

	// Set up API Gateway with KrakenD
	krakendConfig := config.New()
	routerFactory := gin.DefaultFactory(proxy.DefaultFactory(proxy.CustomMiddleware), krakendConfig)
	routerFactory.New().Run(":8080")
}

Adding Distributed Tracing with Jaeger

// config/tracing.go
package config

import (
	"io"
	"log"

	"github.com/opentracing/opentracing-go"
	"github.com/uber/jaeger-client-go"
	"github.com/uber/jaeger-client-go/config"
)

func InitTracing(serviceName string) (io.Closer, error) {
	cfg := config.Configuration{
		Sampler: &config.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &config.ReporterConfig{
			LogSpans:           true,
			LocalAgentHostPort: "127.0.0.1:6831",
		},
	}

	tracer, closer, err := cfg.New(
		serviceName,
		config.Logger(jaeger.StdLogger),
	)
	if err != nil {
		log.Fatalf("ERROR: cannot init Jaeger: %v\n", err)
		return nil, err
	}

	opentracing.SetGlobalTracer(tracer)
	return closer, nil
}
// main.go
package main

import (
	"job-management/config"
	"job-management/internal/routes"
	"github.com/gin-gonic/gin"
)

func main() {
	config.LoadConfig()
	config.ConnectDatabase()

	// Initialize tracing
	tracingCloser, err := config.InitTracing("job-management")
	if err != nil {
		panic(err)
	}
	defer tracingCloser.Close()

	r := gin.Default()
	routes.SetupRouter(r)

	r.Run(":8080")
}

Improving your microservice architecture requires a holistic approach that addresses code quality, performance, scalability, and operational aspects. By adopting best practices in each of these areas, you can create a robust, maintainable, and efficient microservice. Regularly review and refine your architecture and processes to adapt to changing requirements and technological advancements.

Conclusion

In this post, we've created a basic microservice for managing job offers, job applications, and job alerts using Go, Gin, and PostgreSQL. This microservice architecture allows for scalability and easy maintenance, making it ideal for modern application development. You can further enhance this system by adding more features such as authentication, validation, and additional endpoints. Happy coding!