Gin Basics #7 Project Structure and a Mini REST API

5 min read

By the previous post we had covered routing, validation, responses, middleware, and the database. This final post takes the code we have piled into a single main.go and splits it into layers, separates the configuration, and rounds out the skeleton of a small REST API. It is the conclusion of the Gin Basics series.

  • #1 Getting started and your first server
  • #2 Routing and handlers
  • #3 Request binding and validation
  • #4 Responses — JSON, status codes, errors
  • #5 Middleware
  • #6 Database integration (GORM)
  • #7 Project structure and a mini REST API ← this post

The limits of a single file #

So far we have kept all the code in main.go. While you are learning, having everything in one place is convenient, but once the handlers pile up and the models multiply, you hit a wall fast. Routing, business logic, and DB access are all tangled in one place, making it hard to find where to make a change.

The solution is to split files by responsibility. Even for a small project, roughly the following division is enough.

Folder structure
gin-app/
├── main.go            # entry point, wiring
├── config/
│   └── config.go      # configuration
├── database/
│   └── database.go    # DB connection
├── models/
│   └── user.go        # data models
├── handlers/
│   └── user.go        # request handling
└── routes/
    └── routes.go      # route registration

This is not a grand architecture, just separating concerns into “config”, “connection”, “models”, “handlers”, and “routes”. As things grow you might add a service layer between the handlers and DB access, but for getting started I recommend beginning with this structure.

Separating config — config #

Values like the port and DB path are better pulled out into environment variables than hardcoded. It becomes easy to use different values in development and production.

config/config.go
package config

import "os"

type Config struct {
	Port   string
	DBPath string
}

func Load() Config {
	return Config{
		Port:   getEnv("PORT", "8080"),
		DBPath: getEnv("DB_PATH", "app.db"),
	}
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

This is a simple pattern that falls back to a default when the environment variable is absent. If you want to use a .env file you can add a library like github.com/joho/godotenv, but it is not required.

Separating the DB connection — database #

We move the connection code from #6 into its own package.

database/database.go
package database

import (
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"

	"gin-app/models"
)

func Connect(dbPath string) *gorm.DB {
	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
	if err != nil {
		log.Fatal("DB connection failed:", err)
	}
	db.AutoMigrate(&models.User{})
	return db
}

Separating the model — models #

models/user.go
package models

import "time"

type User struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Name      string    `json:"name"`
	Email     string    `json:"email" gorm:"uniqueIndex"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

Separating the handlers — handlers #

Since the handlers need a *gorm.DB, we switch from the closure used in #6 to a struct that holds the dependency. The more handlers you have, the cleaner this is.

handlers/user.go
package handlers

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"

	"gin-app/models"
)

type UserHandler struct {
	DB *gorm.DB
}

func (h *UserHandler) Create(c *gin.Context) {
	var req struct {
		Name  string `json:"name" binding:"required"`
		Email string `json:"email" binding:"required,email"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	user := models.User{Name: req.Name, Email: req.Email}
	if err := h.DB.Create(&user).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
		return
	}
	c.JSON(http.StatusCreated, user)
}

func (h *UserHandler) Get(c *gin.Context) {
	var user models.User
	if err := h.DB.First(&user, c.Param("id")).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
		return
	}
	c.JSON(http.StatusOK, user)
}

func (h *UserHandler) List(c *gin.Context) {
	var users []models.User
	if err := h.DB.Find(&users).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
		return
	}
	c.JSON(http.StatusOK, users)
}

UserHandler holds a DB field, and the methods share it. Grouping the handlers as methods also makes route registration tidy.

Separating the routes — routes #

routes/routes.go
package routes

import (
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"

	"gin-app/handlers"
)

func Register(r *gin.Engine, db *gorm.DB) {
	userHandler := &handlers.UserHandler{DB: db}

	users := r.Group("/users")
	{
		users.POST("", userHandler.Create)
		users.GET("", userHandler.List)
		users.GET("/:id", userHandler.Get)
	}
}

Wiring it together — main.go #

Finally, main.go is left with the single job of loading each piece and connecting them. The code is short, and the whole flow is visible at a glance.

main.go
package main

import (
	"github.com/gin-gonic/gin"

	"gin-app/config"
	"gin-app/database"
	"gin-app/routes"
)

func main() {
	cfg := config.Load()
	db := database.Connect(cfg.DBPath)

	r := gin.Default()
	routes.Register(r, db)

	r.Run(":" + cfg.Port)
}

It reads the config, connects to the DB, creates the router and registers the routes, then starts the server. Because each step lives in a separate package, when you add a handler or swap the DB it is obvious where to touch.

Closing out the series #

Here is a recap of what the Gin Basics series covered.

  • The first server and Gin’s advantages over net/http (#1)
  • Routing, paths, query parameters, router groups (#2)
  • Request binding and validation (#3)
  • Response formats, status codes, consistent errors (#4)
  • Middleware and passing values through the context (#5)
  • CRUD with GORM (#6)
  • A layered project structure (this post)

With this much you can build a small REST API from start to finish. In the next step, the Gin Intermediate series, we will cover JWT authentication, middleware-based centralized error handling, dependency injection, testing, pagination, and Docker deployment. We will grow it into something close to a real production service.

X