Article & Like API with Go + Gin + GORM (Part 1): First, an "implementation-first controller" with all-in-one CRUD

  • gorm
    gorm
  • gin
    gin
  • golang
    golang
Published on 2025/07/13

Introduction

When creating a new API, it’s natural to want a clean design from the very beginning.
But in real-world development, there are many situations where the specs aren’t fully decided yet, or you just need to quickly show something that works.
If you apply Clean Architecture from the start in such cases, development speed can drop and you may end up spending time on unnecessary abstractions.

So in this series, we’ll first build an API that has all the required features packed into an “implementation-first controller,”
and then gradually refactor and improve the design from there.

The theme is “articles and likes.”
We’ll rapidly implement an API with the minimum required features—article CRUD, adding/removing likes, and sorting by popularity—
using Go + Gin + GORM + MySQL.

The goal of Part 1 is to complete, in an intentionally “all-in-one” style:

  • Article CRUD (create, list, get single, update, delete)
  • Adding/removing likes (idempotent API design)
  • Sorting by popularity (like_count DESC)

At this stage, we’ll keep tests and separation of responsibilities to a minimum, and first get things running to pave the way for later improvements.

The implementation for this part is available here:

https://github.com/shinagawa-web/gin-gorm-article-like-example/tree/part1

Features to implement

Articles

  • Create: register a new article
    POST /articles

  • List: get articles with pagination (limit / offset / sort=new|popular)
    GET /articles

  • Get single: get by specifying article ID
    GET /articles/:id

  • Update: update title and body by specifying article ID
    PUT /articles/:id

  • Delete: delete by specifying article ID
    DELETE /articles/:id

Likes

  • Add (Like): add a like to an article (idempotent)
    POST /articles/:id/like

  • Remove (Unlike): remove a like from an article (idempotent)
    DELETE /articles/:id/like

Sorting by popularity

  • When you specify sort=popular in the article list, results are ordered by like_count DESC, id DESC

Tech stack

  • Language / runtime
    Go 1.22+

  • Web framework
    Gin v1.10 series

  • ORM / DB
    GORM v1.25 series

  • gorm.io/driver/mysql v1.5 series
    MySQL 8.4 (started with Docker Compose)

  • Infra helpers
    Docker / Docker Compose (for local DB)

(Optional) golang-migrate (planned for later in this series. In Part 1 we’ll use AutoMigrate for speed.)

  • Verification
    curl (API connectivity)
    httptest (we’ll add minimal regression tests from the next part onward)

Data model (GORM structs)

GORM structs

type Article struct {
	ID        int64     `gorm:"primaryKey;autoIncrement"`
	AuthorID  int64     `gorm:"not null;index"`
	Title     string    `gorm:"size:255;not null"`
	Body      string    `gorm:"type:text;not null"`
	LikeCount int64     `gorm:"not null;default:0;index"`
	CreatedAt time.Time `gorm:"not null;index"`
	UpdatedAt time.Time `gorm:"not null;index"`
}

type Like struct {
	UserID    int64 `gorm:"primaryKey"`
	ArticleID int64 `gorm:"primaryKey;index"`
}

Key points

  • likes uses a composite primary key (user_id, article_id) to prevent duplicates
  • articles.like_count is a derived value (the application maintains consistency)
  • Indexes are prepared for sorting (newest / most popular)

MySQL schema

CREATE TABLE articles (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  author_id BIGINT NOT NULL,
  title VARCHAR(255) NOT NULL,
  body TEXT NOT NULL,
  like_count BIGINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  KEY ix_articles_created_at (created_at DESC),
  KEY ix_articles_popular (like_count DESC, id DESC)
);

CREATE TABLE likes (
  user_id BIGINT NOT NULL,
  article_id BIGINT NOT NULL,
  PRIMARY KEY (user_id, article_id),
  KEY ix_likes_article (article_id),
  CONSTRAINT fk_likes_article FOREIGN KEY (article_id) REFERENCES articles(id)
);

Endpoint implementation (fat controller)

Method Path Description
POST /articles Create article
GET /articles Article list (limit / offset / `sort=new popular`)
GET /articles/:id Get article details
PUT /articles/:id Update article
DELETE /articles/:id Delete article
POST /articles/:id/like?userId=... Add like (idempotent)
DELETE /articles/:id/like?userId=... Remove like (idempotent)

Verifying behavior (curl examples)

# Create
curl -X POST localhost:8080/articles \
  -H "Content-Type: application/json" \
  -d '{"authorId":1,"title":"Hello","body":"First post"}'

# List (newest)
curl "localhost:8080/articles?limit=10&offset=0&sort=new"

# Get single
curl "localhost:8080/articles/1"

# Update
curl -X PUT localhost:8080/articles/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Updated","body":"Updated body"}'

# Delete
curl -X DELETE localhost:8080/articles/1

# Like / Unlike (idempotent)
curl -X POST "localhost:8080/articles/1/like?userId=1"
curl -X DELETE "localhost:8080/articles/1/like?userId=1"

Image from Gyazo

Image from Gyazo

Implementation code

main.go
cmd/api/main.go
package main

import (
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

type Article struct {
	ID        int64     `gorm:"primaryKey;autoIncrement"`
	AuthorID  int64     `gorm:"not null;index"`
	Title     string    `gorm:"size:255;not null"`
	Body      string    `gorm:"type:text;not null"`
	LikeCount int64     `gorm:"not null;default:0;index"`
	CreatedAt time.Time `gorm:"not null;index"`
	UpdatedAt time.Time `gorm:"not null;index"`
}

type Like struct {
	UserID    int64 `gorm:"primaryKey"`
	ArticleID int64 `gorm:"primaryKey;index"`
}

func main() {
	dsn := "user:password@tcp(localhost:3306)/article_like?parseTime=true"
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}

	sqlDB, err := db.DB()
	if err != nil {
		log.Fatal(err)
	}
	defer sqlDB.Close()

	if err := db.AutoMigrate(&Article{}, &Like{}); err != nil {
		log.Fatal(err)
	}

	r := gin.Default()
	r.GET("/healthz", func(c *gin.Context) {
		c.String(http.StatusOK, "ok")
	})

	r.GET("/articles", func(c *gin.Context) {
		limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
		offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
		sort := c.DefaultQuery("sort", "new")

		if limit <= 0 || limit > 100 {
			limit = 20
		}
		if offset < 0 {
			offset = 0
		}

		q := db.Model(&Article{})
		switch sort {
		case "popular":
			q = q.Order("like_count DESC").Order("id DESC")
		case "new":
			q = q.Order("created_at DESC").Order("id DESC")
		default:
			q = q.Order("created_at DESC").Order("id DESC")
		}

		var articles []Article
		if err := q.Limit(limit).Offset(offset).Find(&articles).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch"})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"items":      articles,
			"nextOffset": offset + len(articles),
		})
	})

	r.GET("/articles/:id", func(c *gin.Context) {
		id, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}

		var a Article
		if err := db.First(&a, id).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"id":        a.ID,
			"authorId":  a.AuthorID,
			"title":     a.Title,
			"body":      a.Body,
			"likeCount": a.LikeCount,
			"createdAt": a.CreatedAt,
			"updatedAt": a.UpdatedAt,
		})
	})

	r.POST("/articles", func(c *gin.Context) {
		var req struct {
			AuthorID int64  `json:"authorId" binding:"required"`
			Title    string `json:"title" binding:"required"`
			Body     string `json:"body" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}

		a := &Article{
			AuthorID:  req.AuthorID,
			Title:     req.Title,
			Body:      req.Body,
			CreatedAt: time.Now(),
			UpdatedAt: time.Now(),
		}

		if err := db.Create(a).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create"})
			return
		}

		c.JSON(http.StatusCreated, gin.H{"id": a.ID})
	})

	r.PUT("/articles/:id", func(c *gin.Context) {
		id, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}

		var req struct {
			Title string `json:"title" binding:"required"`
			Body  string `json:"body" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}

		var a Article
		if err := db.First(&a, id).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		a.Title = req.Title
		a.Body = req.Body
		a.UpdatedAt = time.Now()

		if err := db.Save(&a).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update"})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"id": a.ID, "authorId": a.AuthorID, "title": a.Title, "body": a.Body,
			"likeCount": a.LikeCount, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt,
		})
	})

	r.DELETE("/articles/:id", func(c *gin.Context) {
		id, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}

		res := db.Delete(&Article{}, id)
		if res.Error != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
			return
		}
		if res.RowsAffected == 0 {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		c.Status(http.StatusNoContent)
	})

	r.POST("/articles/:id/like", func(c *gin.Context) {
		articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}

		userID, err := strconv.ParseInt(c.DefaultQuery("userId", "1"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid userId"})
			return
		}

		if err := db.Transaction(func(tx *gorm.DB) error {
			res := tx.Clauses(clause.OnConflict{DoNothing: true}).
				Create(&Like{UserID: userID, ArticleID: articleID})
			if res.Error != nil {
				return res.Error
			}

			if res.RowsAffected > 0 {
				if err := tx.Model(&Article{}).
					Where("id = ?", articleID).
					Update("like_count", gorm.Expr("like_count + ?", 1)).Error; err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to like"})
			return
		}
		c.Status(http.StatusNoContent)
	})

	r.DELETE("/articles/:id/like", func(c *gin.Context) {
		articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}

		userID, err := strconv.ParseInt(c.DefaultQuery("userId", "1"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid userId"})
			return
		}

		if err := db.Transaction(func(tx *gorm.DB) error {
			res := tx.Where("user_id = ? AND article_id = ?", userID, articleID).
				Delete(&Like{})
			if res.Error != nil {
				return res.Error
			}

			if res.RowsAffected > 0 {
				if err := tx.Model(&Article{}).
					Where("id = ?", articleID).
					Update("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error; err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unlike"})
			return
		}
		c.Status(http.StatusNoContent)
	})

	log.Println("listening on :8080")
	r.Run(":8080")
}

First, put everything into main.go

In this implementation, we put everything in main.go, the entry point of the application:

  • Routing
  • Handler functions
  • DB access (GORM)
  • Transaction handling

The reason is simple: to very quickly build something that works and solidify the specs and I/O image.

If you try to properly split packages and set up layered architecture from the start, you’ll immediately face design decisions like:

  • What should be treated as an entity?
  • Where should we draw the boundaries of the UseCase layer?
  • How granular should the repository interfaces be?

and you end up spending time before you can even verify how the API behaves.

So for now, we’ll deliberately take this stance.

Part 1:

  • Write everything in main.go
  • Split functions only minimally
  • Lock down “what this API returns”

From Part 2 onward:

  • Separate into handler, service, and repository
  • Move transaction boundaries
  • Add and improve tests

Summary & next steps

In this part (Part 1), we:

  • Centralized all processing in main.go and implemented article CRUD and Like/Unlike as quickly as possible
  • Completed the API in an “implementation-first” style, including pagination and sorting (newest / popular)
  • Omitted user management and used a simple structure where authorId and userId are specified directly

What matters at this stage is:

  • Solidifying API behavior and I/O
  • Leaving room to later refactor boundaries and responsibilities

In the next part (Part 2), we’ll refactor mainly around:

  • Splitting into handler, service, and repository layers
  • Moving transaction boundaries to the UseCase layer
  • Organizing the Like/Unlike logic from a domain perspective
  • Adding minimal regression tests so we can safely improve the code

The implementation for this part is available here:

https://github.com/shinagawa-web/gin-gorm-article-like-example/tree/part1

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form