AA
Ayi Akbar
Basic CRUD Using GORM: Models, Migrations, and Database Operations - GORM in Go Part 2

Basic CRUD Using GORM: Models, Migrations, and Database Operations - GORM in Go Part 2

May 6, 2025 Ayi Akbar Maulana

After getting familiar with the basics of GORM in the previous article, it's time to dive into one of the most fundamental aspects of working with databases—CRUD operations (Create, Read, Update, Delete). GORM offers a clean and efficient way to perform these operations in Go.

In this article, we’ll walk through:

  • Creating a User model to represent our data structure
  • Setting up database migrations using GORM’s auto-migration feature
  • Implementing basic CRUD operations to manage user data These steps will serve as a strong foundation before moving on to more advanced topics like model relationships, validations, and database transactions.

1. Models

Models define your data structures. With GORM, they also map directly to your database schema (tables, fields, constraints).

These are typically used across both the service and repository layers.

pkg/models/user_model.go

package models

import (
	"time"

	"gorm.io/gorm"
)

type User struct {
	ID        string         `gorm:"primary_key" json:"id"`
	Username  string         `gorm:"not null;unique" json:"username"`
	Email     string         `gorm:"not null;unique" json:"email"`
	Password  string         `gorm:"not null" json:"password"`
	CreatedAt time.Time      `gorm:"autoCreateTime" json:"created_at"`
	UpdatedAt time.Time      `gorm:"autoUpdateTime" json:"updated_at"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}

Now, let’s create a migration file

pkg/database/migration.go

package database

import (
	"gorm-in-go/pkg/models"
	"log"

	"gorm.io/gorm"
)

func Migrate(db *gorm.DB) error {

	// Auto Migrate
	err := db.AutoMigrate(&models.User{})
	if err != nil {
		log.Fatal("Error migrating database: ", err)
	}

	return nil
}

Let’s test the migration by modifying our main.go file

cmd/main.go

package main

import (
	server "gorm-in-go/internal/api/http"
	"gorm-in-go/pkg/database"
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	godotenv.Load()

	// Initialize database
	db, err := database.InitDB(
		os.Getenv("DB_HOST"),     // host
		os.Getenv("DB_USER"),     // user
		os.Getenv("DB_PASSWORD"), // password
		os.Getenv("DB_NAME"),     // database name
		os.Getenv("DB_PORT"),     // port
	)

	if err != nil {
		log.Fatal(err)
	}

	// Run migrations
	err = database.Migrate(db)
	if err != nil {
		log.Fatal(err)
	}

	log.Println("Database migration completed successfully")

	server.StartServer()
}

Let's run the app

go run cmd/main.go

If you see the following output, then the migration was successful

go-app-run-successfully

Great, now we have a database and a table called users. Let's create our CRUD operations.

2. Repository Layer

This layer is responsible for all interactions with the database. It contains logic to perform CRUD (Create, Read, Update, Delete) operations for your entities, such as User.

Why it matters: By isolating database logic in the repository layer, you can:

  • Avoid spreading SQL or ORM logic across your codebase
  • Make your code easier to test (via interface mocking)
  • Replace or switch your data source with minimal changes to the rest of the app

pkg/app/user/user_repository.go

package repository

import (
	"errors"
	"gorm-in-go/common/error_handler"
	"gorm-in-go/pkg/models"

	"gorm.io/gorm"
)

type UserRepository interface {
	CreateUser(user *models.User) error
	GetUserById(id string) (*models.User, error)
	UpdateUser(id string, user *models.User) error
	DeleteUser(id string) error
}

type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) UserRepository {
	return &userRepository{db: db}
}

func (r *userRepository) CreateUser(user *models.User) error {
	return r.db.Create(user).Error
}

func (r *userRepository) GetUserById(id string) (*models.User, error) {
	var user models.User
	if err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&user).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, error_handler.ErrUserNotFound
		}
		return nil, err
	}
	return &user, nil
}

func (r *userRepository) UpdateUser(id string, user *models.User) error {
	var existingUser models.User
	if err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&existingUser).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return error_handler.ErrUserNotFound
		}
		return err
	}

	existingUser.Password = user.Password
	existingUser.Username = user.Username

	return r.db.Model(&existingUser).Updates(user).Error
}

func (r *userRepository) DeleteUser(id string) error {
	if err := r.db.Where("id = ? AND deleted_at IS NULL", id).Delete(&models.User{}).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return error_handler.ErrUserNotFound
		}
		return err
	}
	return nil
}

3. Service Layer

This is where your business logic lives. Services use repositories to access the data and apply any business rules or validation logic.

Examples of tasks:

  • Validate input before saving
  • Execute additional processes before or after database operations

Why it matters: Service layers make your app more maintainable and modular by separating "what the app does" from "how it gets the data."

pkg/app/user/user_service.go

package service

import (
	"gorm-in-go/pkg/app/user/repository"
	"gorm-in-go/pkg/models"

	"github.com/google/uuid"
	"golang.org/x/crypto/bcrypt"
)

type UserService interface {
	CreateUser(createUserRequest *CreateUserRequest) error
	GetUserById(id string) (*models.User, error)
	UpdateUser(id string, updateUserRequest *UpdateUserRequest) error
	DeleteUser(id string) error
}

type userService struct {
	userRepo repository.UserRepository
}

func NewUserService(userRepo repository.UserRepository) UserService {
	return &userService{userRepo: userRepo}
}

func (s *userService) CreateUser(createUserRequest *CreateUserRequest) error {
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(createUserRequest.Password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}

	user := models.User{
		ID:       uuid.New().String(),
		Email:    createUserRequest.Email,
		Password: string(hashedPassword),
		Username: createUserRequest.Username,
	}

	return s.userRepo.CreateUser(&user)
}

func (s *userService) GetUserById(id string) (*models.User, error) {
	return s.userRepo.GetUserById(id)
}

func (s *userService) UpdateUser(id string, updateUserRequest *UpdateUserRequest) error {
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(updateUserRequest.Password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	user := models.User{
		Password: string(hashedPassword),
		Username: updateUserRequest.Username,
	}

	return s.userRepo.UpdateUser(id, &user)
}

func (s *userService) DeleteUser(id string) error {
	return s.userRepo.DeleteUser(id)
}

type CreateUserRequest struct {
	Email    string `json:"email" validate:"required,email"`
	Password string `json:"password" validate:"required,min=8"`
	Username string `json:"username" validate:"required,min=3"`
}

type UpdateUserRequest struct {
	Password string `json:"password" validate:"required,min=8"`
	Username string `json:"username" validate:"required,min=3"`
}

4. HTTP / Handle Layer

The handler layer is the entry point for HTTP requests. It handles routing, parses input, calls the appropriate service, and returns the response.

Why it matters: Handlers should focus only on:

  • Receiving HTTP requests
  • Mapping them to service calls
  • Formatting the HTTP responses

This ensures your web framework (e.g., Fiber) is decoupled from your core logic.

internal/api/http/user/user_handler.go

package user_http

import (
	"errors"
	"gorm-in-go/common/error_handler"
	"gorm-in-go/pkg/app/user/service"

	"github.com/go-playground/validator"
	"github.com/gofiber/fiber/v2"
)

var validate = validator.New()

type UserHandler struct {
	userService service.UserService
}

func NewUserHandler(userService service.UserService) *UserHandler {
	return &UserHandler{userService: userService}
}

func (h *UserHandler) CreateUser(c *fiber.Ctx) error {

	var createUserRequest service.CreateUserRequest
	if err := c.BodyParser(&createUserRequest); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
	}

	if err := validate.Struct(createUserRequest); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
	}

	if err := h.userService.CreateUser(&createUserRequest); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
	}

	return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "User created successfully"})
}

func (h *UserHandler) GetUserById(c *fiber.Ctx) error {
	id := c.Query("id")
	if id == "" {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing user id"})
	}

	user, err := h.userService.GetUserById(id)
	if err != nil {
		if errors.Is(err, error_handler.ErrUserNotFound) {
			return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
		}
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
	}

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"user": user})
}

func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
	id := c.Query("id")

	var updateUserRequest service.UpdateUserRequest

	if err := c.BodyParser(&updateUserRequest); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
	}

	if err := h.userService.UpdateUser(id, &updateUserRequest); err != nil {
		if errors.Is(err, error_handler.ErrUserNotFound) {
			return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
		}
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
	}

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "User updated successfully"})
}

func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
	id := c.Query("id")

	if err := h.userService.DeleteUser(id); err != nil {
		if errors.Is(err, error_handler.ErrUserNotFound) {
			return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
		}
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
	}

	return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "User deleted successfully"})
}

5. Routes / API Endpoints

Now let’s define the routes that will serve our user-related API endpoints.

internal/api/http/user/user_routes.go

package user_http

import (
	"gorm-in-go/pkg/app/user/repository"
	"gorm-in-go/pkg/app/user/service"
	"gorm-in-go/pkg/database"

	"github.com/gofiber/fiber/v2"
)

func UserRoutes(app *fiber.App) {
	user := app.Group("/user")
	userHandler := NewUserHandler(service.NewUserService(repository.NewUserRepository(database.GetDB())))

	user.Post("/", userHandler.CreateUser)
	user.Get("/", userHandler.GetUserById)
	user.Put("/", userHandler.UpdateUser)
	user.Delete("/", userHandler.DeleteUser)
}

This function sets up routes under the /user group and links each HTTP method to its corresponding handler function.

6. Register Routes in server.go

To activate the routes we just defined, we need to register them in our main server setup.

internal/api/http/server.go


package server

import (
	user_http "gorm-in-go/internal/api/http/user"

	"github.com/gofiber/fiber/v2"
)

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

	app.Get("/", func(c *fiber.Ctx) error {
		return c.SendString("Hello World!")
	})

	// Register routes
	user_http.UserRoutes(app)

	app.Listen(":8080")
}

With this setup, our application now supports basic HTTP endpoints for user-related operations, making it ready for real interaction with clients.

We’ve reached the final part of this article — testing our API endpoints using PowerShell on Windows. You can use any tools you like to test the endpoints e.g Postman, curl, etc.

Create User

curl -Method POST http://localhost:8080/user `
-Headers @{ "Content-Type" = "application/json" } `
-Body '{ "email": "ayiakbar@tempmail.org", "password": "qwerty123", "username": "ayiakbar" }'

Get User

curl http://localhost:8080/user?id=1

Update User

curl -Method PUT http://localhost:8080/user?id=1 `
-Headers @{ "Content-Type" = "application/json" } `
-Body '{ "password": "qwerty123", "username": "ayiakbar1" }'

Delete User

curl -Method DELETE http://localhost:8080/user?id=1

Note: Replace the id parameter with the actual user ID you created. You can find it by checking your database directly.

Conclusion

In this article, we’ve successfully built a simple CRUD API in Go using GORM and the Fiber web framework, following Clean Architecture principles. We’ve covered everything from setting up the project and database, to defining models, services, repositories, routes, and testing endpoints.

This foundation will make it easier to scale and maintain your Go applications moving forward. In the next part, we’ll explore more advanced topics such as middleware, authentication, and relationships between models.

I hope you enjoyed this article. If you have any questions, please feel free to ask.

You can view the complete project on GitHub