Go + Gin + GORMで作る記事&いいねAPI(Part 1)まずは“動かすこと優先のコントローラー”で全部入りCRUD
はじめに
新しいAPIを作るとき、「最初からきれいな設計に仕上げたい」と思うのは自然なことです。
しかし実際の開発現場では、仕様が固まっていない段階や、とにかく動くものを早く見せたい場面も多くあります。
そんなときにいきなりクリーンアーキテクチャを適用すると、開発スピードが落ちたり、不要な抽象化に時間を取られることもあります。
そこで本シリーズでは、まずは“動かすこと優先のコントローラー”で必要な機能を全部入れたAPIを作り、
そこから少しずつ設計を整理していくプロセスを紹介します。
題材は「記事といいね」。
記事のCRUD、いいねの追加/削除、人気順ソートといった最低限の機能を持ったAPIを、
Go + Gin + GORM + MySQL で最速実装していきます。
第1回のゴールは、
- 記事のCRUD(作成・一覧・単体取得・更新・削除)
- いいねの追加/削除(冪等なAPI設計)
- 人気順ソート(like_count DESC)
までを、あえて“全部入り”の形で完成させることです。
この時点ではテストや責務分離は最小限に留め、まず動かしてから改善へ向かう道筋を作ります
今回の実装はこちらに載せてあります。
実装対象の機能
記事
-
作成:記事を新規登録する
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"
実装コード
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 のロジックをドメイン的に整理
- 最小限の回帰テストを追加して、安全に改善できる状態にする
今回の実装はこちらに載せてあります。
関連する技術ブログ
Go × Echoで始めるWebサーバ構築入門:シンプル・高速なAPI開発を最短で学ぶ
Go言語で軽量・高速なWebアプリケーションを構築したい方へ。人気フレームワーク「Echo」の導入から基本構造、ルーティング、レスポンス、ミドルウェアの使い方までを、実践的なサンプルコードとともにわかりやすく解説します。Ginとの違いにも触れながら、最小構成でWebサーバを立ち上げるまでを丁寧にガイドします。
shinagawa-web.com
Go × Gin 応用編:実践テクニックとスケーラブルなAPI設計
Ginの強力な機能を活かし、認証・バリデーション・テスト・本番運用までを体系的に学びます。さらに、スケーラブルなAPI設計の考え方も取り上げ、実務に活かせる応用編です。
shinagawa-web.com
Go × Gin 基礎編:高速APIサーバーの作り方を徹底解説
Go言語の特性を活かし、Ginフレームワークでシンプルかつ強力なAPIサーバーを構築する方法をステップバイステップで解説します。インストールからミドルウェア、サンプルAPI作成まで、実践的な内容を盛り込んだ入門記事です。
shinagawa-web.com
Go × Gin でMVC構成のブログ記事投稿用Web APIを構築する:基礎からスケーラブル設計まで
Go言語とGinフレームワークを使って、MVC構成のWeb APIを一から構築していきます。ディレクトリ設計からルーティング、ミドルウェアの活用まで、実践的なコードで丁寧に解説。スケーラブルな設計の基本を学びたい方におすすめの入門編です。
shinagawa-web.com
Go × Gin × MVC構成で実践する堅牢なテスト設計と実装ガイド
単体テスト、統合テスト、E2Eテストを通して、GinベースのMVC構成アプリを堅牢に育てる方法を解説します。httptestの使い方やモックの作り方、共通処理の抽出など、実務に活かせるテスト設計のエッセンスを凝縮しました。
shinagawa-web.com
Go + Ginアプリを本番品質に仕上げる:設定・構成・CI導入まで
アプリを「動く」から「本番で保てる」品質へ引き上げるために、設定ファイルの整理、依存の分離、GitHub ActionsによるCI導入までを解説します。小規模開発からチーム開発へスムーズに移行したい方に向けた応用編です。
shinagawa-web.com
Go向け文字列スライス操作ユーティリティ「strlistutils」を公開しました
Go言語でよく使う []string に対して「重複削除」「空文字除去」「トリム」などを簡単に行えるユーティリティパッケージ strlistutils を公開しました。小さく依存ゼロ、テストも充実しており、日常的なデータ整形をシンプルに書けるのが特徴です。
shinagawa-web.com
Go言語でWeb APIサーバーを作る完全ガイド|設計・開発フロー・テスト・CI/CDまで徹底解説
Go言語でWeb APIサーバーを作りたい方向けに、設計方針、APIスキーマ(OpenAPI/Swagger)、エンドポイント実装、テスト(単体・統合・E2E)、ミドルウェアの設定、CI/CD自動化までを詳しく解説します。初心者でもわかりやすく、開発フローを段階的にまとめた完全ガイドです。
shinagawa-web.com