
Basic CRUD Using GORM: Models, Migrations, and Database Operations - GORM in Go Part 2
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
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