Go Project Setup Guide

init setup go project image

When building a backend web application in Go, structuring the project with Hexagonal Architecture ensures maintainability, testability, and flexibility. In this guide, we'll set up a Go backend project with the following technologies:

  • Cobra CLI for command management
  • Go Fiber for the HTTP server
  • Swaggo for API documentation
  • Gorm with PostgreSQL for database interactions
  • Viper for environment variable management
  • Logrus for structured logging

# Install Go

Before starting, you need to install Go. Visit the official Go website to download the installer. The installation process varies by operating system:

  • Windows: Use the MSI installer and follow the setup instructions.
  • MacOS: Use Homebrew with brew install go or download the macOS package.
  • Linux: Use your package manager, such as sudo apt install golang for Debian-based systems or sudo dnf install golang for Fedora.

go version

# Project Initialization

First, create a new directory with the name of backend service

mkdir go-fiber-temp && cd go-fiber-temp

the, initialize go module with this command

go mod init go-fiber-temp

File go.mod will be created.

I will use Hexagonal Architecture Pattern. So, the folder structure will be like this.

go-fiber-temp/
│── cmd/                         # Entry points (CLI, HTTP)
│  │── http.go                   # Starts HTTP server
│  │── root.go                   # Root command for CLI (Cobra)
│── db/                          # Database-related logic
│  │── migrations/               # Database migration scripts
│── docs/                        # Swaggo API documentation
│── internal/                    # Internal application logic
│  │── adapter/                  # Implementations of ports
│  │  │── http/                  # HTTP handlers/controllers
│  │  │── repository/            # Repository implementations (Gorm, etc.)
│  │  │── external/              # External API implementation
│  │── app/                      # Application services (Use cases)
│  │  │── dto/                   # DTOs for HTTP request/response
│  │  │── serviceimpl/           # Service implementations (business logic)
│  │── config/                   # Internal configuration handling
│  │── domain/                   # Domain entities and aggregates
│  │── port/                     # Interfaces (e.g., repository, service, external APIs)
│  │  │── repository/            # Repository interfaces
│  │  │── external/              # External API interface
│  │  │── service/               # Service interfaces
│── main.go                      # Application entry point
│── go.mod                       # Go module file
│── go.sum                       # Go dependencies checksum

You can use this command to create necesary folder.

mkdir -p db/migrations docs internal/{app/{dto,serviceimpl},config,domain,port/{repository,external,service},adapter/{http,repository,external}}
find . -type d -empty -exec touch {}/.gitkeep \;

* main.go and cmd folder (also file inside it) will be automatically created on setup cobra-cli

Install testify lib for testing on project.

go get github.com/stretchr/testify

# Setting Up Cobra CLI

Install cobra-cli, if you already install, you can skip this command

go install github.com/spf13/cobra-cli@latest

Initialize cobra-cli in this backend project

cobra-cli init

after you initiate with cobra-cli, the folder structure will be like this.

go-fiber-temp/
│── cmd/
│   ├── root.go
│── main.go

Make cmd/root.go simpler

package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

// rootCmd represents the base command
var rootCmd = &cobra.Command{
	Use:   "template-2025-feb",
	Short: "A CLI application using Cobra",
}

// Execute runs the root command
func Execute() {
	if err := rootCmd.Execute(); err != nil {
		os.Exit(1)
	}
}

# # HTTP Server

Add http command, to handle running http server

cobra-cli add http

There will be http.go file under cmd folder

go-fiber-temp/
│── cmd/
│   ├── root.go
│   ├── http.go

I will use Fiber. Fiber is HTTP Web Framework that help developer build http server easily. This is command to install fiber dependency.

go get -u github.com/gofiber/fiber/v2

Edit file cmd/http.go

package cmd

import (
	"github.com/gofiber/fiber/v2"
	"github.com/spf13/cobra"
	"log"
)

// httpCmd represents the command to start the Fiber HTTP server
var httpCmd = &cobra.Command{
	Use:   "http",
	Short: "Start the Fiber HTTP server",
	Run: func(cmd *cobra.Command, args []string) {
		startHTTPServer()
	},
}

func startHTTPServer() {
	app := fiber.New()

	port := "9090"

	log.Fatal(app.Listen(":" + port))
}

func init() {
	rootCmd.AddCommand(httpCmd)
}

# Setting Up Configuration

There is a few setup that need to be done

# # Viper

Install dependency

go get github.com/spf13/viper

Create new file .env (Only needed while running local. So, put this file on .gitignore). For production mode, we will use os env variable.

touch .env
HTTP_PORT=8080

DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=userdb

LOG_LEVEL=info

Create new file internal/config/viper.go

touch internal/config/viper.go
package config

import (
	"fmt"
	"github.com/spf13/viper"
)

func LoadConfig(fileName string) {
	viper.AutomaticEnv()

	viper.SetConfigFile(fileName)
	viper.SetConfigType("env")
	if err := viper.ReadInConfig(); err != nil {
		fmt.Println("There is no env file. It is a problem if you run in local mode. If it is not, then it is ok.")
	}
}

Create unit test to check, are we can get value from env variable on dev or prod mod.

touch internal/config/viper_test.go
package config

import (
	"os"
	"testing"

	"github.com/spf13/viper"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// Test LoadConfig in production mode (reading from OS environment variables)
func TestLoadConfig_Production(t *testing.T) {
	defer os.Clearenv()
	os.Setenv("HTTP_PORT", "9090")
	os.Setenv("DB_HOST", "prod-db")
	os.Setenv("DB_PORT", "5433")
	os.Setenv("DB_USER", "prod_user")
	os.Setenv("DB_PASSWORD", "prod_password")
	os.Setenv("DB_NAME", "prod_db")
	os.Setenv("LOG_LEVEL", "warn")

	viper.Reset()
	LoadConfig("")

	assert.Equal(t, "9090", viper.GetString("HTTP_PORT"))
	assert.Equal(t, "prod-db", viper.GetString("DB_HOST"))
	assert.Equal(t, 5433, viper.GetInt("DB_PORT"))
	assert.Equal(t, "prod_user", viper.GetString("DB_USER"))
	assert.Equal(t, "prod_password", viper.GetString("DB_PASSWORD"))
	assert.Equal(t, "prod_db", viper.GetString("DB_NAME"))
	assert.Equal(t, "warn", viper.GetString("LOG_LEVEL"))
}

// Test LoadConfig in development mode (reading from .env)
func TestLoadConfig_Development(t *testing.T) {
	fileName := ".env.test"
	mockEnv := `HTTP_PORT=8080
				
				DB_HOST=localhost
				DB_PORT=5432
				DB_USER=postgres
				DB_PASSWORD=password
				DB_NAME=userdb
				
				LOG_LEVEL=info
				`
	err := os.WriteFile(fileName, []byte(mockEnv), 0644)
	require.NoError(t, err)
	defer os.Remove(fileName)

	viper.Reset()
	LoadConfig(fileName)

	assert.Equal(t, "8080", viper.GetString("HTTP_PORT"))
	assert.Equal(t, "localhost", viper.GetString("DB_HOST"))
	assert.Equal(t, 5432, viper.GetInt("DB_PORT"))
	assert.Equal(t, "postgres", viper.GetString("DB_USER"))
	assert.Equal(t, "password", viper.GetString("DB_PASSWORD"))
	assert.Equal(t, "userdb", viper.GetString("DB_NAME"))
	assert.Equal(t, "info", viper.GetString("LOG_LEVEL"))
}

Now, we can update cmd/http.go to get http port value from environtment variable

package cmd

import (
	"github.com/gofiber/fiber/v2"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"log"
	"template-2025-feb/internal/config"
)

// httpCmd represents the command to start the Fiber HTTP server
var httpCmd = &cobra.Command{
	Use:   "http",
	Short: "Start the Fiber HTTP server",
	Run: func(cmd *cobra.Command, args []string) {
		config.LoadConfig(".env")
		startHTTPServer()
	},
}

func startHTTPServer() {
	app := fiber.New()

	port := viper.GetString("HTTP_PORT")
	if port == "" {
		port = "9090"
	}

	log.Fatal(app.Listen(":" + port))
}

func init() {
	rootCmd.AddCommand(httpCmd)
}

# # Logrus

Install dependency

go get github.com/sirupsen/logrus

Create new file internal/config/logrus.go. Get the log level from env variable. if there is not set, then the log level is info. I also using json formatter for the log.

touch internal/config/logrus.go
package config

import (
	"github.com/sirupsen/logrus"
	"github.com/spf13/viper"
)

var Logger *logrus.Logger

func InitLogger() {
	Logger = logrus.New()

	level, err := logrus.ParseLevel(viper.GetString("LOG_LEVEL"))
	if err != nil {
		level = logrus.InfoLevel
	}
	Logger.SetLevel(level)

	Logger.SetFormatter(&logrus.JSONFormatter{})
}

Create unit test to check setting log level with table test

touch internal/config/logrus_test.go
package config

import (
	"os"
	"testing"

	"github.com/sirupsen/logrus"
	"github.com/spf13/viper"
	"github.com/stretchr/testify/assert"
)

func TestInitLogger(t *testing.T) {
	originalLogLevel := os.Getenv("LOG_LEVEL")
	defer os.Setenv("LOG_LEVEL", originalLogLevel)

	testCases := []struct {
		name          string
		logLevel      string
		expectedLevel logrus.Level
	}{
		{"Default Level (INFO)", "", logrus.InfoLevel},
		{"Debug Level", "debug", logrus.DebugLevel},
		{"Warn Level", "warn", logrus.WarnLevel},
		{"Error Level", "error", logrus.ErrorLevel},
		{"Invalid Level (Fallback to INFO)", "invalid", logrus.InfoLevel},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			os.Setenv("LOG_LEVEL", tc.logLevel)
			viper.AutomaticEnv()
			InitLogger()

			assert.Equal(t, tc.expectedLevel, Logger.GetLevel())
		})
	}
}

Update cmd/http.go to add initate logger

package cmd

import (
	"github.com/gofiber/fiber/v2"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"template-2025-feb/internal/config"
)

// httpCmd represents the command to start the Fiber HTTP server
var httpCmd = &cobra.Command{
	Use:   "http",
	Short: "Start the Fiber HTTP server",
	Run: func(cmd *cobra.Command, args []string) {
		config.LoadConfig(".env")
		config.InitLogger()

		config.Logger.Info("Starting HTTP server...")
		startHTTPServer()
	},
}

func startHTTPServer() {
	app := fiber.New()

	port := viper.GetString("HTTP_PORT")
	if port == "" {
		port = "9090"
	}

	config.Logger.Fatal(app.Listen(":" + port))
}

func init() {
	rootCmd.AddCommand(httpCmd)
}

# # GORM

I use posgresql on docker for development mode.

touch docker-compose.yml
services:
  db:
    image: postgres
    restart: always
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydatabase
    ports:
      - "5432:5432"

Then you can run it with this command.

docker compose up -d

Install dependency

go get -u gorm.io/gorm gorm.io/driver/postgres

Create custom logrus adapter for gorm.

touch internal/config/gorm_logrus.go
package config

import (
	"context"
	"time"

	"github.com/sirupsen/logrus"
	"gorm.io/gorm/logger"
)

// GormLogrusLogger implements Gorm's logger.Interface using Logrus.
type GormLogrusLogger struct {
	LogLevel logger.LogLevel
}

// LogMode sets the log level dynamically.
func (l *GormLogrusLogger) LogMode(level logger.LogLevel) logger.Interface {
	newLogger := *l
	newLogger.LogLevel = level
	return &newLogger
}

// Info logs information-level messages.
func (l *GormLogrusLogger) Info(ctx context.Context, msg string, data ...interface{}) {
	if l.LogLevel >= logger.Info {
		Logger.WithContext(ctx).Infof(msg, data...)
	}
}

// Warn logs warning-level messages.
func (l *GormLogrusLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
	if l.LogLevel >= logger.Warn {
		Logger.WithContext(ctx).Warnf(msg, data...)
	}
}

// Error logs error-level messages.
func (l *GormLogrusLogger) Error(ctx context.Context, msg string, data ...interface{}) {
	if l.LogLevel >= logger.Error {
		Logger.WithContext(ctx).Errorf(msg, data...)
	}
}

// Trace logs SQL queries, execution time, and errors.
func (l *GormLogrusLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
	if l.LogLevel <= logger.Silent {
		return
	}

	sql, rows := fc()
	duration := time.Since(begin)

	entry := Logger.WithContext(ctx).WithFields(logrus.Fields{
		"sql":      sql,
		"rows":     rows,
		"duration": duration,
	})

	switch {
	case err != nil && l.LogLevel >= logger.Error:
		entry.WithError(err).Error("SQL execution error")
	case duration > time.Millisecond*200 && l.LogLevel >= logger.Warn: // Slow queries (200ms threshold)
		entry.Warn("Slow SQL query")
	case l.LogLevel >= logger.Info:
		entry.Info("SQL executed")
	}
}

Create unit test logrus adapter for gorm.

touch internal/config/gorm_logrus_test.go
package config

import (
	"context"
	"gorm.io/gorm/logger"
	"testing"
	"time"

	"github.com/sirupsen/logrus"
	"github.com/sirupsen/logrus/hooks/test"
	"github.com/stretchr/testify/assert"
)

func setupLoggerTest() (*GormLogrusLogger, *test.Hook) {
	loggerInstance := logrus.New()
	hook := test.NewLocal(loggerInstance)

	Logger = loggerInstance // Override global Logger with test logger
	return &GormLogrusLogger{LogLevel: logger.Info}, hook
}

func TestGormLogrusLogger_Info(t *testing.T) {
	gormLogger, hook := setupLoggerTest()
	gormLogger.Info(context.Background(), "test message")

	assert.NotEmpty(t, hook.Entries, "Expected log entry, but got none")
	assert.Equal(t, "test message", hook.LastEntry().Message)
	assert.Equal(t, logrus.InfoLevel, hook.LastEntry().Level)
	hook.Reset()
}

func TestGormLogrusLogger_Warn(t *testing.T) {
	gormLogger, hook := setupLoggerTest()
	gormLogger.Warn(context.Background(), "test warning")

	assert.NotEmpty(t, hook.Entries, "Expected log entry, but got none")
	assert.Equal(t, "test warning", hook.LastEntry().Message)
	assert.Equal(t, logrus.WarnLevel, hook.LastEntry().Level)
	hook.Reset()
}

func TestGormLogrusLogger_Error(t *testing.T) {
	gormLogger, hook := setupLoggerTest()
	gormLogger.Error(context.Background(), "test error")

	assert.NotEmpty(t, hook.Entries, "Expected log entry, but got none")
	assert.Equal(t, "test error", hook.LastEntry().Message)
	assert.Equal(t, logrus.ErrorLevel, hook.LastEntry().Level)
	hook.Reset()
}

func TestGormLogrusLogger_Trace(t *testing.T) {
	gormLogger, hook := setupLoggerTest()

	startTime := time.Now()
	gormLogger.Trace(context.Background(), startTime, func() (string, int64) {
		return "SELECT * FROM users", 1
	}, nil)

	assert.NotEmpty(t, hook.Entries, "Expected log entry, but got none")
	assert.Contains(t, hook.LastEntry().Data, "sql")
	assert.Equal(t, "SQL executed", hook.LastEntry().Message)
}

Create new file internal/config/gorm.go. I use postgre database.

touch internal/config/gorm.go
package config

import (
	"fmt"
	"time"

	"github.com/spf13/viper"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var DB *gorm.DB

func InitDB() {
	var gormLogLevel logger.LogLevel
	switch viper.GetString("LOG_LEVEL") {
	case "debug":
		gormLogLevel = logger.Info
	case "warn":
		gormLogLevel = logger.Warn
	case "error":
		gormLogLevel = logger.Error
	default:
		gormLogLevel = logger.Silent
	}

	dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
		viper.GetString("DB_HOST"),
		viper.GetInt("DB_PORT"),
		viper.GetString("DB_USER"),
		viper.GetString("DB_PASSWORD"),
		viper.GetString("DB_NAME"),
	)

	gormConfig := &gorm.Config{
		Logger: &GormLogrusLogger{LogLevel: gormLogLevel},
	}

	db, err := gorm.Open(postgres.Open(dsn), gormConfig)
	if err != nil {
		Logger.Fatal("Failed to connect to database: ", err)
	}

	sqlDB, err := db.DB()
	if err != nil {
		Logger.Fatal("Failed to get database instance: ", err)
	}

	sqlDB.SetMaxIdleConns(10)
	sqlDB.SetMaxOpenConns(100)
	sqlDB.SetConnMaxLifetime(5 * time.Minute)

	DB = db
	Logger.Info("Database connection established successfully!")
}

Create unit test to check setting log level with table test

touch internal/config/gorm_test.go
package config

import (
	"os"
	"testing"

	"github.com/spf13/viper"
	"github.com/stretchr/testify/assert"
)

// TestInitDB tests if the database initializes without errors
func TestInitDB(t *testing.T) {
	os.Setenv("DB_HOST", "localhost")
	os.Setenv("DB_PORT", "5432")
	os.Setenv("DB_USER", "user")
	os.Setenv("DB_PASSWORD", "password")
	os.Setenv("DB_NAME", "mydatabase")
	os.Setenv("LOG_INFO", "info")

	viper.AutomaticEnv()
	InitLogger()
	InitDB()

	assert.NotNil(t, DB, "Database instance should not be nil")
	sqlDB, err := DB.DB()
	assert.NoError(t, err, "Should retrieve database instance without errors")
	assert.NoError(t, sqlDB.Ping(), "Database should be reachable")
}

Update cmd/http.go to add initate gorm

package cmd

import (
	"github.com/gofiber/fiber/v2"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"template-2025-feb/internal/config"
)

// httpCmd represents the command to start the Fiber HTTP server
var httpCmd = &cobra.Command{
	Use:   "http",
	Short: "Start the Fiber HTTP server",
	Run: func(cmd *cobra.Command, args []string) {
		config.LoadConfig(".env")
		config.InitLogger()
		config.InitDB()

		config.Logger.Info("Starting HTTP server...")
		startHTTPServer()
	},
}

func startHTTPServer() {
	app := fiber.New()

	port := viper.GetString("HTTP_PORT")
	if port == "" {
		port = "9090"
	}

	config.Logger.Fatal(app.Listen(":" + port))
}

func init() {
	rootCmd.AddCommand(httpCmd)
}

# # Swaggo

First, install Swag CLI.

go install github.com/swaggo/swag/cmd/swag@latest

Then, install Swaggo dependency on the project. Because I will use GO Fiber. I use this dependency.

go get -u github.com/swaggo/fiber-swagger