Article & Like API with Go + Gin + GORM (Part 1): First, an "implementation-first controller" with all-in-one CRUD
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:
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"
Implementation code
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:
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
2024/03/07Getting Started with Building Web Servers Using Go × Echo: Learn Simple, High‑Performance API Development in the Shortest Time
2023/12/03Go × Gin Advanced: Practical Techniques and Scalable API Design
2023/11/29Go × Gin Basics: A Thorough Guide to Building a High-speed API Server
2023/11/23Building an MVC-Structured Blog Post Web API with Go × Gin: From Basics to Scalable Design
2023/12/03Robust Test Design and Implementation Guide in a Go × Gin × MVC Architecture
2023/12/04Bringing a Go + Gin App Up to Production Quality: From Configuration and Structure to CI
2023/12/06Released a string slice utility for Go: “strlistutils”
2025/06/19

