Go + Gin + GORMで作る記事&いいねAPI(Part 1)まずは“動かすこと優先のコントローラー”で全部入りCRUD

  • golang
    golang
  • gin
    gin
  • gorm
    gorm
2025/07/13に公開

はじめに

新しいAPIを作るとき、「最初からきれいな設計に仕上げたい」と思うのは自然なことです。
しかし実際の開発現場では、仕様が固まっていない段階や、とにかく動くものを早く見せたい場面も多くあります。
そんなときにいきなりクリーンアーキテクチャを適用すると、開発スピードが落ちたり、不要な抽象化に時間を取られることもあります。

そこで本シリーズでは、まずは“動かすこと優先のコントローラー”で必要な機能を全部入れたAPIを作り、
そこから少しずつ設計を整理していくプロセスを紹介します。

題材は「記事といいね」。
記事のCRUD、いいねの追加/削除、人気順ソートといった最低限の機能を持ったAPIを、
Go + Gin + GORM + MySQL で最速実装していきます。

第1回のゴールは、

  • 記事のCRUD(作成・一覧・単体取得・更新・削除)
  • いいねの追加/削除(冪等なAPI設計)
  • 人気順ソート(like_count DESC)

までを、あえて“全部入り”の形で完成させることです。
この時点ではテストや責務分離は最小限に留め、まず動かしてから改善へ向かう道筋を作ります

今回の実装はこちらに載せてあります。

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

実装対象の機能

記事

  • 作成:記事を新規登録する
    POST /articles

  • 一覧取得:記事をページングして取得(limit / offset / sort=new|popular)
    GET /articles

  • 単体取得:記事IDを指定して取得
    GET /articles/:id

  • 更新:記事IDを指定してタイトルと本文を更新
    PUT /articles/:id

  • 削除:記事IDを指定して削除
    DELETE /articles/:id

いいね

  • 追加(Like):記事にいいねを付ける(冪等)
    POST /articles/:id/like

  • 削除(Unlike):記事のいいねを取り消す(冪等)
    DELETE /articles/:id/like

人気順ソート

  • 記事一覧で sort=popular を指定すると、like_count DESC, id DESC の順に並び替える

技術スタック

  • 言語/ランタイム
    Go 1.22+

  • Webフレームワーク
    Gin v1.10系

  • ORM / DB
    GORM v1.25系

  • gorm.io/driver/mysql v1.5系
    MySQL 8.4(Docker Compose で起動)

  • インフラ補助
    Docker / Docker Compose(ローカルDB用)

(任意)golang-migrate(本連載では後半で導入予定。Part1は AutoMigrate で手早く)

  • 動作確認
    curl(API疎通)
    httptest(次回以降で最小の回帰テスト追加)

データモデル(GORM構造体)

GORM 構造体

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"`
}

ポイント

  • likes は (user_id, article_id) の複合主キーで重複を防止
  • articles.like_count は 派生値(アプリ側で一貫性を担保)
  • 並び替え用のインデックスを用意(新着・人気順)

MySQL スキーマ

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)
);

エンドポイント実装(ファットコントローラー)

メソッド パス 説明
POST /articles 記事作成
GET /articles 記事一覧(limit / offset / `sort=new popular`)
GET /articles/:id 記事詳細取得
PUT /articles/:id 記事更新
DELETE /articles/:id 記事削除
POST /articles/:id/like?userId=... いいね追加(冪等)
DELETE /articles/:id/like?userId=... いいね取消(冪等)

動作確認(curl例)

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

# 一覧(新着)
curl "localhost:8080/articles?limit=10&offset=0&sort=new"

# 単体
curl "localhost:8080/articles/1"

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

# 削除
curl -X DELETE localhost:8080/articles/1

# いいね / 取消(冪等)
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

実装コード

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")
}

まずは main.go に全部入れる

今回の実装は、アプリケーションの入り口である main.go に

  • ルーティング
  • ハンドラ関数
  • DBアクセス(GORM)
  • トランザクション処理

といった全ての処理をまとめています。

理由はシンプルで、まずは動く形を最速で作り、仕様や入出力のイメージを固めるためです。

最初からパッケージ分割や層構造を整えようとすると、

  • どこまでをエンティティにするか
  • UseCase層の境界はどう切るか
  • Repositoryインターフェースの粒度は?

といった設計判断が増え、APIの振る舞いを確かめる前に時間を消費してしまいます。

そこで今回は、あえてこう割り切ります。

Part1:

  • main.go に全部書く
  • 関数は最低限に分割
  • 「このAPIで何を返すか」を確定させる

Part2以降:

  • ハンドラ・サービス・リポジトリに分離
  • トランザクションの境界を移す
  • テストの追加・改善

まとめ & 次回予告

今回(Part 1)は、

  • main.go に全処理を集約して、記事の CRUD と Like/Unlike を最短で実装
  • ページング・ソート(新着 / 人気)を含め、“動かすこと優先”の形で API を完成
  • ユーザー管理は省き、authorId と userId は直接指定するシンプル構成にした

この段階で重要なのは、

  • API の振る舞いと I/O を固めること
  • 後のリファクタで境界や責務を整理する余地を残すこと

次回(Part 2) では、下記を中心にリファクタしていきます。

  • ハンドラ・サービス・リポジトリ層へ分離
  • トランザクション境界を UseCase 層に移動
  • Like/Unlike のロジックをドメイン的に整理
  • 最小限の回帰テストを追加して、安全に改善できる状態にする

今回の実装はこちらに載せてあります。

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

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

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ