【第6回】ポートフォリオを作る ── 設計・DI・テスト戦略・README・デプロイ

Go
B!

シリーズ構成

  1. Go の基本文法
  2. CLI ツールを作る
  3. HTTP サーバーと REST API
  4. データベース連携
  5. 実務的な周辺技術
  6. ポートフォリオを作る(本記事)
  7. 実践知識を深める

この回の目標は 「採用担当者やレビュアーに見せられる品質の API を完成させ、GitHub に公開し、動く URL を用意する」 ことです。

ここまでの記事で部品はすべて揃っています。この回では 部品を組み合わせる設計 と、他人に伝える技術(README、コミット、ドキュメント)に重点を置きます。


目次

  1. ポートフォリオの評価基準
  2. 題材の選び方
  3. ディレクトリ構成
  4. レイヤードアーキテクチャ
  5. ドメインモデルとエラー設計
  6. 依存注入と main.go
  7. 各層の実装
  8. エラーの層間変換
  9. テスト戦略
  10. API ドキュメント(OpenAPI)
  11. README の書き方
  12. Git とコミットの作法
  13. デプロイして URL を持つ
  14. レビューされる前のセルフチェック
  15. 演習問題

1. ポートフォリオの評価基準

採用側がコードを見るときに確認する点を先に知っておきます。

観点具体的に見られること
動くかREADME の手順通りに docker compose up で立ち上がるか。動く URL があるか
構造ディレクトリと層の分け方に一貫性があるか。責務が混ざっていないか
エラー処理エラーを握り潰していないか。文脈をラップしているか。適切な HTTP ステータスか
テスト存在するか。何をテストしているか。-race で通るか
セキュリティSQL インジェクション対策、認証・認可、秘密情報の扱い
運用の意識ログ、設定の外部化、Graceful Shutdown、ヘルスチェック
コミット履歴意味のある単位で刻まれているか。「fix」「wip」ばかりでないか
文章力README で設計意図を説明できているか

巨大な機能より、小さくても隙のない実装 が評価されます。


2. 題材の選び方

Todo API を完成させれば十分ですが、差別化したい場合は以下の観点で 1 つ選びます。

題材学びどころ追加要素
Todo API(本記事)基本のすべて認証、ページネーション、タグ
URL 短縮サービスキャッシュ、リダイレクト、統計Redis、アクセス集計
ブックマーク管理OGP 取得(外部 HTTP)、全文検索goroutine、PostgreSQL FTS
家計簿 API集計クエリ、月次レポートウィンドウ関数、CSV エクスポート
在庫管理トランザクション、楽観ロック在庫引当の競合制御
通知サービス非同期処理、リトライワーカー、キュー

自分が 実際に使うもの を選ぶと、細部まで作り込むモチベーションが続きます。

本記事では Todo API を 「認証付き・ページネーション付き・タグ機能付き」 に拡張して完成させます。


3. ディレクトリ構成

todo-api/
├── cmd/
│   └── server/
│       └── main.go              # 起動と依存の組み立てのみ
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── domain/                  # ビジネスの型とルール。外部依存なし
│   │   ├── todo.go
│   │   ├── user.go
│   │   └── errors.go
│   ├── service/                 # ユースケース。domain と repository の interface に依存
│   │   ├── todo.go
│   │   ├── todo_test.go
│   │   ├── auth.go
│   │   └── auth_test.go
│   ├── repository/              # 永続化。domain に依存
│   │   ├── postgres/
│   │   │   ├── db.go
│   │   │   ├── todo.go
│   │   │   ├── todo_test.go
│   │   │   ├── user.go
│   │   │   └── testing.go
│   │   └── interfaces.go        # service が期待する interface(または service 側に置く)
│   ├── handler/                 # HTTP。service に依存
│   │   ├── router.go
│   │   ├── todo.go
│   │   ├── todo_test.go
│   │   ├── auth.go
│   │   ├── response.go          # writeJSON, writeError, エラー変換
│   │   └── middleware/
│   │       ├── auth.go
│   │       ├── logging.go
│   │       └── recover.go
│   ├── auth/                    # JWT・パスワード(横断的な道具)
│   │   ├── token.go
│   │   └── password.go
│   └── logging/
│       └── logging.go
├── db/
│   ├── migrations/
│   └── queries/                 # sqlc 用(使う場合)
├── api/
│   └── openapi.yaml             # API 仕様
├── .github/
│   └── workflows/
│       └── ci.yml
├── Dockerfile
├── compose.yaml
├── Makefile
├── .golangci.yml
├── .env.example
├── go.mod
├── go.sum
└── README.md

なぜこの構成か

  • cmd/:エントリポイント。複数のバイナリ(server、migrate、worker)を持てる
  • internal/:Go の言語機能で外部モジュールから import 不可。ライブラリとして公開しない前提を明示
  • domain/:他の層に依存しない 中心。database/sql も net/http も import しない
  • 層ごとにパッケージ:依存の方向をコンパイラに強制させる(下の層が上を import すると循環参照でエラー)

pkg/ ディレクトリは「外部にも公開するライブラリ」用です。アプリでは通常不要です。


4. レイヤードアーキテクチャ

┌─────────────────────────────────────────┐
│  handler(HTTP)                         │  リクエスト/レスポンスの変換だけ
├─────────────────────────────────────────┤
│  service(ユースケース)                  │  ビジネスルール、トランザクション境界
├─────────────────────────────────────────┤
│  repository(永続化)                     │  SQL だけ
├─────────────────────────────────────────┤
│  domain(型・ルール・エラー)             │  すべての層から参照される中心
└─────────────────────────────────────────┘

依存の方向

handler → service → repository(interface)
   ↓         ↓            ↓
         domain
  • 上の層は下の層を知ってよい。下の層は上の層を知らない
  • service は repository の インターフェース に依存し、実装(postgres)を知らない
  • domain は 誰にも依存しない

各層の責務

層やることやらないこと
handlerJSON デコード、バリデーション(形式)、認証情報の取り出し、service 呼び出し、ステータスコード決定ビジネスルール、SQL
serviceビジネスルール(権限、状態遷移、整合性)、複数 repository の組み合わせ、トランザクションHTTP の概念(ステータスコード、ヘッダ)
repositorySQL の実行、DB エラーの domain エラーへの変換ビジネスルール
domain型定義、不変条件、エラー定義I/O

判断に迷ったとき:「この処理は HTTP を gRPC に変えても必要か?」→ Yes なら service 以下。「DB を MySQL に変えても必要か?」→ Yes なら service 以上。

過剰設計を避ける

小さなプロジェクトで DDD の全部(Entity、ValueObject、Aggregate、Factory…)を持ち込む必要はありません。Go の文化は 「必要になったら足す」 です。この 4 層で、Todo API 程度なら十分に整理されます。


5. ドメインモデルとエラー設計

domain/todo.go

package domain

import (
    "strings"
    "time"
)

type Todo struct {
    ID          int64
    UserID      int64
    Title       string
    Description string
    Done        bool
    DueDate     *time.Time
    Tags        []string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

const (
    MaxTitleLength       = 200
    MaxDescriptionLength = 2000
    MaxTags              = 10
)

// Validate はビジネス上の不変条件を検査する
func (t *Todo) Validate() error {
    var errs ValidationErrors

    title := strings.TrimSpace(t.Title)
    if title == "" {
        errs.Add("title", "must not be empty")
    } else if len([]rune(title)) > MaxTitleLength {
        errs.Add("title", "must be at most 200 characters")
    }

    if len([]rune(t.Description)) > MaxDescriptionLength {
        errs.Add("description", "must be at most 2000 characters")
    }

    if len(t.Tags) > MaxTags {
        errs.Add("tags", "at most 10 tags allowed")
    }
    for _, tag := range t.Tags {
        if tag == "" || len(tag) > 30 {
            errs.Add("tags", "each tag must be 1-30 characters")
            break
        }
    }

    if t.DueDate != nil && t.DueDate.Before(time.Now().Truncate(24*time.Hour)) {
        errs.Add("due_date", "must not be in the past")
    }

    return errs.ErrOrNil()
}

// IsOverdue は期限超過かを返す
func (t *Todo) IsOverdue(now time.Time) bool {
    return t.DueDate != nil && !t.Done && now.After(*t.DueDate)
}

domain/errors.go

エラーの 種類 をここで定義し、全層で共有します。

package domain

import (
    "errors"
    "fmt"
    "strings"
)

// センチネルエラー:呼び出し側が errors.Is で判定する
var (
    ErrNotFound      = errors.New("not found")
    ErrUnauthorized  = errors.New("unauthorized")
    ErrForbidden     = errors.New("forbidden")
    ErrConflict      = errors.New("conflict")
    ErrInvalidInput  = errors.New("invalid input")
)

// ValidationErrors はフィールドごとの検証エラー
type ValidationErrors struct {
    Fields map[string]string
}

func (v *ValidationErrors) Add(field, msg string) {
    if v.Fields == nil {
        v.Fields = make(map[string]string)
    }
    v.Fields[field] = msg
}

func (v ValidationErrors) Error() string {
    parts := make([]string, 0, len(v.Fields))
    for f, m := range v.Fields {
        parts = append(parts, f+": "+m)
    }
    return "validation failed: " + strings.Join(parts, ", ")
}

func (v ValidationErrors) ErrOrNil() error {
    if len(v.Fields) == 0 {
        return nil
    }
    return v
}

// Is で ErrInvalidInput として判定できるようにする
func (v ValidationErrors) Is(target error) bool {
    return target == ErrInvalidInput
}

// NotFoundError は何が見つからなかったかを持つ
type NotFoundError struct {
    Resource string
    ID       any
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %v not found", e.Resource, e.ID)
}

func (e *NotFoundError) Is(target error) bool {
    return target == ErrNotFound
}

Is メソッドを実装すると、errors.Is(err, domain.ErrNotFound) で 具体的な型もセンチネルも同時に判定 できます。


6. 依存注入と main.go

Go では DI コンテナを使わず、main で手で組み立てる のが主流です。依存の流れが一目で分かり、魔法がありません。

// cmd/server/main.go
package main

import (
    "context"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/joho/godotenv"

    "github.com/yourname/todo-api/internal/auth"
    "github.com/yourname/todo-api/internal/config"
    "github.com/yourname/todo-api/internal/handler"
    "github.com/yourname/todo-api/internal/logging"
    "github.com/yourname/todo-api/internal/repository/postgres"
    "github.com/yourname/todo-api/internal/service"
)

var version = "dev" // -ldflags で上書き

func main() {
    if err := run(); err != nil {
        slog.Error("fatal", "err", err)
        os.Exit(1)
    }
}

func run() error {
    _ = godotenv.Load()

    cfg, err := config.Load()
    if err != nil {
        return fmt.Errorf("config: %w", err)
    }

    logger := logging.Setup(cfg.LogLevel)
    logger.Info("starting", "version", version, "port", cfg.Port)

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    // --- インフラ ---
    db, err := postgres.Open(ctx, cfg.DatabaseURL)
    if err != nil {
        return fmt.Errorf("database: %w", err)
    }
    defer db.Close()

    // --- リポジトリ ---
    userRepo := postgres.NewUserRepository(db)
    todoRepo := postgres.NewTodoRepository(db)

    // --- 横断的な道具 ---
    tokens := auth.NewTokenService(cfg.JWTSecret, cfg.JWTExpiry)

    // --- サービス ---
    authSvc := service.NewAuthService(userRepo, tokens)
    todoSvc := service.NewTodoService(todoRepo, db)

    // --- HTTP ---
    router := handler.NewRouter(handler.Deps{
        Auth:           authSvc,
        Todos:          todoSvc,
        Tokens:         tokens,
        DB:             db,
        AllowedOrigins: cfg.AllowedOrigins,
        Version:        version,
    })

    srv := &http.Server{
        Addr:              fmt.Sprintf(":%d", cfg.Port),
        Handler:           router,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       15 * time.Second,
        WriteTimeout:      30 * time.Second,
        IdleTimeout:       120 * time.Second,
    }

    errCh := make(chan error, 1)
    go func() {
        logger.Info("listening", "addr", srv.Addr)
        if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
        }
    }()

    select {
    case err := <-errCh:
        return fmt.Errorf("server: %w", err)
    case <-ctx.Done():
        logger.Info("shutdown signal received")
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        return fmt.Errorf("shutdown: %w", err)
    }
    logger.Info("stopped cleanly")
    return nil
}

main は run() を呼ぶだけ にし、run が error を返す形にすると defer が正しく動き、テストも可能になります(os.Exit は defer を実行しません)。


7. 各層の実装

repository のインターフェース(service 側に置く)

「使う側がインターフェースを定義する」Go の慣習に従い、service パッケージに置きます。

// internal/service/todo.go
package service

import (
    "context"
    "database/sql"
    "fmt"

    "github.com/yourname/todo-api/internal/domain"
)

type TodoRepository interface {
    Create(ctx context.Context, todo *domain.Todo) error
    Get(ctx context.Context, userID, id int64) (*domain.Todo, error)
    List(ctx context.Context, f TodoFilter) ([]*domain.Todo, int, error)
    Update(ctx context.Context, todo *domain.Todo) error
    Delete(ctx context.Context, userID, id int64) error
}

type TodoFilter struct {
    UserID int64
    Done   *bool
    Tag    string
    Query  string
    Limit  int
    Offset int
}

type TodoService struct {
    repo TodoRepository
    db   *sql.DB // トランザクション用。sqlc なら pgxpool
}

func NewTodoService(repo TodoRepository, db *sql.DB) *TodoService {
    return &TodoService{repo: repo, db: db}
}

type CreateTodoInput struct {
    Title       string
    Description string
    DueDate     *time.Time
    Tags        []string
}

func (s *TodoService) Create(ctx context.Context, userID int64, in CreateTodoInput) (*domain.Todo, error) {
    todo := &domain.Todo{
        UserID:      userID,
        Title:       strings.TrimSpace(in.Title),
        Description: strings.TrimSpace(in.Description),
        DueDate:     in.DueDate,
        Tags:        normalizeTags(in.Tags),
    }
    if err := todo.Validate(); err != nil {
        return nil, err // ValidationErrors をそのまま返す
    }
    if err := s.repo.Create(ctx, todo); err != nil {
        return nil, fmt.Errorf("create todo: %w", err)
    }
    return todo, nil
}

func (s *TodoService) Get(ctx context.Context, userID, id int64) (*domain.Todo, error) {
    return s.repo.Get(ctx, userID, id)
}

type ListResult struct {
    Items []*domain.Todo
    Total int
}

func (s *TodoService) List(ctx context.Context, f TodoFilter) (*ListResult, error) {
    if f.Limit <= 0 || f.Limit > 100 {
        f.Limit = 20
    }
    if f.Offset < 0 {
        f.Offset = 0
    }
    items, total, err := s.repo.List(ctx, f)
    if err != nil {
        return nil, fmt.Errorf("list todos: %w", err)
    }
    return &ListResult{Items: items, Total: total}, nil
}

type UpdateTodoInput struct {
    Title       *string
    Description *string
    Done        *bool
    DueDate     *time.Time
    ClearDue    bool
    Tags        *[]string
}

// Update は部分更新。nil のフィールドは変更しない
func (s *TodoService) Update(ctx context.Context, userID, id int64, in UpdateTodoInput) (*domain.Todo, error) {
    todo, err := s.repo.Get(ctx, userID, id)
    if err != nil {
        return nil, err
    }

    if in.Title != nil {
        todo.Title = strings.TrimSpace(*in.Title)
    }
    if in.Description != nil {
        todo.Description = strings.TrimSpace(*in.Description)
    }
    if in.Done != nil {
        todo.Done = *in.Done
    }
    if in.ClearDue {
        todo.DueDate = nil
    } else if in.DueDate != nil {
        todo.DueDate = in.DueDate
    }
    if in.Tags != nil {
        todo.Tags = normalizeTags(*in.Tags)
    }

    if err := todo.Validate(); err != nil {
        return nil, err
    }
    if err := s.repo.Update(ctx, todo); err != nil {
        return nil, fmt.Errorf("update todo %d: %w", id, err)
    }
    return todo, nil
}

func (s *TodoService) Delete(ctx context.Context, userID, id int64) error {
    return s.repo.Delete(ctx, userID, id)
}

func normalizeTags(tags []string) []string {
    seen := make(map[string]struct{}, len(tags))
    out := make([]string, 0, len(tags))
    for _, t := range tags {
        t = strings.ToLower(strings.TrimSpace(t))
        if t == "" {
            continue
        }
        if _, dup := seen[t]; dup {
            continue
        }
        seen[t] = struct{}{}
        out = append(out, t)
    }
    return out
}

service に置くべきロジック:正規化(trim、小文字化、重複除去)、部分更新の適用、デフォルト値、上限のクランプ。これらは HTTP にも DB にも依存しません。

repository(postgres)

タグは TEXT[] 型で保存すると JOIN 不要で簡潔です。

-- db/migrations/000003_add_tags.up.sql
ALTER TABLE todos ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX idx_todos_tags ON todos USING GIN (tags);
// internal/repository/postgres/todo.go
package postgres

import (
    "context"
    "database/sql"
    "errors"
    "fmt"

    "github.com/lib/pq" // pq.Array 用。pgx なら不要(ネイティブ対応)

    "github.com/yourname/todo-api/internal/domain"
    "github.com/yourname/todo-api/internal/service"
)

type TodoRepository struct{ db *sql.DB }

func NewTodoRepository(db *sql.DB) *TodoRepository { return &TodoRepository{db: db} }

// コンパイル時にインターフェースを満たすことを確認
var _ service.TodoRepository = (*TodoRepository)(nil)

const cols = `id, user_id, title, description, done, due_date, tags, created_at, updated_at`

func scan(row interface{ Scan(...any) error }, t *domain.Todo) error {
    return row.Scan(&t.ID, &t.UserID, &t.Title, &t.Description, &t.Done,
        &t.DueDate, pq.Array(&t.Tags), &t.CreatedAt, &t.UpdatedAt)
}

func (r *TodoRepository) Create(ctx context.Context, t *domain.Todo) error {
    row := r.db.QueryRowContext(ctx, `
        INSERT INTO todos (user_id, title, description, due_date, tags)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING `+cols,
        t.UserID, t.Title, t.Description, t.DueDate, pq.Array(t.Tags))
    return scan(row, t)
}

func (r *TodoRepository) Get(ctx context.Context, userID, id int64) (*domain.Todo, error) {
    var t domain.Todo
    err := scan(r.db.QueryRowContext(ctx,
        `SELECT `+cols+` FROM todos WHERE id = $1 AND user_id = $2`, id, userID), &t)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, &domain.NotFoundError{Resource: "todo", ID: id}
    }
    if err != nil {
        return nil, fmt.Errorf("get todo: %w", err)
    }
    return &t, nil
}

func (r *TodoRepository) List(ctx context.Context, f service.TodoFilter) ([]*domain.Todo, int, error) {
    where := `WHERE user_id = $1
        AND ($2::boolean IS NULL OR done = $2)
        AND ($3::text = '' OR $3 = ANY(tags))
        AND ($4::text = '' OR title ILIKE '%' || $4 || '%')`
    args := []any{f.UserID, f.Done, f.Tag, f.Query}

    var total int
    if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM todos `+where, args...).Scan(&total); err != nil {
        return nil, 0, fmt.Errorf("count todos: %w", err)
    }

    rows, err := r.db.QueryContext(ctx,
        `SELECT `+cols+` FROM todos `+where+` ORDER BY created_at DESC, id DESC LIMIT $5 OFFSET $6`,
        append(args, f.Limit, f.Offset)...)
    if err != nil {
        return nil, 0, fmt.Errorf("list todos: %w", err)
    }
    defer rows.Close()

    todos := make([]*domain.Todo, 0, f.Limit)
    for rows.Next() {
        var t domain.Todo
        if err := scan(rows, &t); err != nil {
            return nil, 0, fmt.Errorf("scan todo: %w", err)
        }
        todos = append(todos, &t)
    }
    return todos, total, rows.Err()
}

func (r *TodoRepository) Update(ctx context.Context, t *domain.Todo) error {
    row := r.db.QueryRowContext(ctx, `
        UPDATE todos SET title=$3, description=$4, done=$5, due_date=$6, tags=$7, updated_at=NOW()
        WHERE id = $1 AND user_id = $2
        RETURNING `+cols,
        t.ID, t.UserID, t.Title, t.Description, t.Done, t.DueDate, pq.Array(t.Tags))
    err := scan(row, t)
    if errors.Is(err, sql.ErrNoRows) {
        return &domain.NotFoundError{Resource: "todo", ID: t.ID}
    }
    return err
}

func (r *TodoRepository) Delete(ctx context.Context, userID, id int64) error {
    res, err := r.db.ExecContext(ctx, `DELETE FROM todos WHERE id = $1 AND user_id = $2`, id, userID)
    if err != nil {
        return fmt.Errorf("delete todo: %w", err)
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return &domain.NotFoundError{Resource: "todo", ID: id}
    }
    return nil
}

handler

// internal/handler/todo.go
package handler

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

    "github.com/yourname/todo-api/internal/domain"
    "github.com/yourname/todo-api/internal/handler/middleware"
    "github.com/yourname/todo-api/internal/service"
)

type TodoHandler struct {
    svc *service.TodoService
}

// --- リクエスト/レスポンス DTO ---

type createTodoRequest struct {
    Title       string   `json:"title"`
    Description string   `json:"description"`
    DueDate     *string  `json:"due_date"` // "2025-12-31"
    Tags        []string `json:"tags"`
}

type updateTodoRequest struct {
    Title       *string   `json:"title"`
    Description *string   `json:"description"`
    Done        *bool     `json:"done"`
    DueDate     *string   `json:"due_date"` // "" を送ると期限クリア
    Tags        *[]string `json:"tags"`
}

type todoResponse struct {
    ID          int64     `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Done        bool      `json:"done"`
    DueDate     *string   `json:"due_date"`
    Tags        []string  `json:"tags"`
    Overdue     bool      `json:"overdue"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

type listResponse struct {
    Items  []todoResponse `json:"items"`
    Total  int            `json:"total"`
    Limit  int            `json:"limit"`
    Offset int            `json:"offset"`
}

func toTodoResponse(t *domain.Todo) todoResponse {
    r := todoResponse{
        ID: t.ID, Title: t.Title, Description: t.Description, Done: t.Done,
        Tags: t.Tags, Overdue: t.IsOverdue(time.Now()),
        CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
    }
    if r.Tags == nil {
        r.Tags = []string{}
    }
    if t.DueDate != nil {
        s := t.DueDate.Format("2006-01-02")
        r.DueDate = &s
    }
    return r
}

func parseDate(s *string) (*time.Time, error) {
    if s == nil || *s == "" {
        return nil, nil
    }
    t, err := time.Parse("2006-01-02", *s)
    if err != nil {
        return nil, err
    }
    return &t, nil
}

// --- ハンドラ ---

func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
    userID := middleware.UserIDFrom(r.Context())

    var in createTodoRequest
    if err := decodeJSON(r, &in); err != nil {
        writeError(w, r, err)
        return
    }
    due, err := parseDate(in.DueDate)
    if err != nil {
        writeValidation(w, map[string]string{"due_date": "must be YYYY-MM-DD"})
        return
    }

    todo, err := h.svc.Create(r.Context(), userID, service.CreateTodoInput{
        Title: in.Title, Description: in.Description, DueDate: due, Tags: in.Tags,
    })
    if err != nil {
        writeError(w, r, err)
        return
    }
    w.Header().Set("Location", "/api/v1/todos/"+strconv.FormatInt(todo.ID, 10))
    writeJSON(w, http.StatusCreated, toTodoResponse(todo))
}

func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    f := service.TodoFilter{
        UserID: middleware.UserIDFrom(r.Context()),
        Tag:    q.Get("tag"),
        Query:  q.Get("q"),
        Limit:  queryInt(q.Get("limit"), 20),
        Offset: queryInt(q.Get("offset"), 0),
    }
    if d := q.Get("done"); d != "" {
        b := d == "true"
        f.Done = &b
    }

    res, err := h.svc.List(r.Context(), f)
    if err != nil {
        writeError(w, r, err)
        return
    }
    items := make([]todoResponse, len(res.Items))
    for i, t := range res.Items {
        items[i] = toTodoResponse(t)
    }
    writeJSON(w, http.StatusOK, listResponse{Items: items, Total: res.Total, Limit: f.Limit, Offset: f.Offset})
}

func (h *TodoHandler) Get(w http.ResponseWriter, r *http.Request) {
    id, ok := pathID(w, r)
    if !ok {
        return
    }
    todo, err := h.svc.Get(r.Context(), middleware.UserIDFrom(r.Context()), id)
    if err != nil {
        writeError(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toTodoResponse(todo))
}

func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
    id, ok := pathID(w, r)
    if !ok {
        return
    }
    var in updateTodoRequest
    if err := decodeJSON(r, &in); err != nil {
        writeError(w, r, err)
        return
    }

    upd := service.UpdateTodoInput{
        Title: in.Title, Description: in.Description, Done: in.Done, Tags: in.Tags,
    }
    if in.DueDate != nil {
        if *in.DueDate == "" {
            upd.ClearDue = true
        } else {
            d, err := parseDate(in.DueDate)
            if err != nil {
                writeValidation(w, map[string]string{"due_date": "must be YYYY-MM-DD"})
                return
            }
            upd.DueDate = d
        }
    }

    todo, err := h.svc.Update(r.Context(), middleware.UserIDFrom(r.Context()), id, upd)
    if err != nil {
        writeError(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, toTodoResponse(todo))
}

func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id, ok := pathID(w, r)
    if !ok {
        return
    }
    if err := h.svc.Delete(r.Context(), middleware.UserIDFrom(r.Context()), id); err != nil {
        writeError(w, r, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil || id <= 0 {
        writeValidation(w, map[string]string{"id": "must be a positive integer"})
        return 0, false
    }
    return id, true
}

func queryInt(s string, def int) int {
    if s == "" {
        return def
    }
    n, err := strconv.Atoi(s)
    if err != nil {
        return def
    }
    return n
}

router

// internal/handler/router.go
package handler

import (
    "database/sql"
    "net/http"

    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/cors"

    "github.com/yourname/todo-api/internal/auth"
    "github.com/yourname/todo-api/internal/handler/middleware"
    "github.com/yourname/todo-api/internal/service"
)

type Deps struct {
    Auth           *service.AuthService
    Todos          *service.TodoService
    Tokens         *auth.TokenService
    DB             *sql.DB
    AllowedOrigins []string
    Version        string
}

func NewRouter(d Deps) http.Handler {
    r := chi.NewRouter()

    r.Use(chimw.RequestID)
    r.Use(chimw.RealIP)
    r.Use(middleware.Recover)
    r.Use(middleware.Logging)
    r.Use(chimw.Timeout(30 * time.Second))
    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   d.AllowedOrigins,
        AllowedMethods:   []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Content-Type", "Authorization"},
        AllowCredentials: true,
        MaxAge:           300,
    }))

    r.Get("/healthz", healthz(d.DB, d.Version))

    authH := &AuthHandler{svc: d.Auth}
    todoH := &TodoHandler{svc: d.Todos}

    r.Route("/api/v1", func(r chi.Router) {
        r.Post("/signup", authH.Signup)
        r.Post("/login", authH.Login)

        r.Group(func(r chi.Router) {
            r.Use(middleware.Auth(d.Tokens))

            r.Get("/me", authH.Me)

            r.Route("/todos", func(r chi.Router) {
                r.Get("/", todoH.List)
                r.Post("/", todoH.Create)
                r.Get("/{id}", todoH.Get)
                r.Patch("/{id}", todoH.Update)
                r.Delete("/{id}", todoH.Delete)
            })
        })
    })

    return r
}

8. エラーの層間変換

各層で エラーの表現を適切に変換 することが、保守しやすい API の鍵です。

repository: sql.ErrNoRows       → domain.NotFoundError
            pq unique_violation → domain.ErrConflict
service:    domain.Validate()   → domain.ValidationErrors
handler:    domain.ErrNotFound  → 404
            domain.ErrConflict  → 409
            ValidationErrors    → 422 + fields
            その他             → 500 + ログ

writeError を 1 か所 に集約します。

// internal/handler/response.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/yourname/todo-api/internal/domain"
    "github.com/yourname/todo-api/internal/logging"
)

type errorBody struct {
    Error  string            `json:"error"`
    Fields map[string]string `json:"fields,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    if v != nil {
        _ = json.NewEncoder(w).Encode(v)
    }
}

func writeValidation(w http.ResponseWriter, fields map[string]string) {
    writeJSON(w, http.StatusUnprocessableEntity, errorBody{Error: "validation failed", Fields: fields})
}

// writeError はドメインエラーを HTTP ステータスに変換する唯一の場所
func writeError(w http.ResponseWriter, r *http.Request, err error) {
    var ve domain.ValidationErrors
    var be *badRequestError

    switch {
    case errors.As(err, &ve):
        writeValidation(w, ve.Fields)
    case errors.As(err, &be):
        writeJSON(w, http.StatusBadRequest, errorBody{Error: be.Error()})
    case errors.Is(err, domain.ErrNotFound):
        writeJSON(w, http.StatusNotFound, errorBody{Error: "not found"})
    case errors.Is(err, domain.ErrUnauthorized):
        writeJSON(w, http.StatusUnauthorized, errorBody{Error: "unauthorized"})
    case errors.Is(err, domain.ErrForbidden):
        writeJSON(w, http.StatusForbidden, errorBody{Error: "forbidden"})
    case errors.Is(err, domain.ErrConflict):
        writeJSON(w, http.StatusConflict, errorBody{Error: err.Error()})
    default:
        logging.FromContext(r.Context()).Error("unhandled error", "err", err)
        writeJSON(w, http.StatusInternalServerError, errorBody{Error: "internal server error"})
    }
}

type badRequestError struct{ msg string }

func (e *badRequestError) Error() string { return e.msg }

func decodeJSON(r *http.Request, v any) error {
    r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(v); err != nil {
        return &badRequestError{msg: "invalid request body: " + err.Error()}
    }
    return nil
}

効果:ハンドラは writeError(w, r, err) と書くだけになり、ステータスコードの判断が一箇所に集まります。新しいエラー種別を追加するときもここだけ変更します。


9. テスト戦略

層ごとに 何をどうテストするか を決めます。

層テスト方法速度何を確認
domain純粋なユニットテスト極速バリデーション、ビジネスルール
servicerepository をモック速正規化、部分更新、エラー伝播
repository本物の PostgreSQL中SQL の正しさ、制約、NotFound
handlerservice をモックまたは実物 + httptest速〜中ステータス、JSON 形式、認証
E2ECompose で全部起動 + HTTP クライアント遅シナリオ全体

domain のテスト

// internal/domain/todo_test.go
func TestTodo_Validate(t *testing.T) {
    tomorrow := time.Now().Add(24 * time.Hour)
    yesterday := time.Now().Add(-24 * time.Hour)

    tests := []struct {
        name      string
        todo      Todo
        wantField string // 空ならエラーなし
    }{
        {"valid", Todo{Title: "ok", DueDate: &tomorrow}, ""},
        {"empty title", Todo{Title: "   "}, "title"},
        {"title too long", Todo{Title: strings.Repeat("あ", 201)}, "title"},
        {"too many tags", Todo{Title: "ok", Tags: make([]string, 11)}, "tags"},
        {"past due", Todo{Title: "ok", DueDate: &yesterday}, "due_date"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.todo.Validate()
            if tt.wantField == "" {
                if err != nil {
                    t.Fatalf("unexpected error: %v", err)
                }
                return
            }
            var ve ValidationErrors
            if !errors.As(err, &ve) {
                t.Fatalf("want ValidationErrors, got %T", err)
            }
            if _, ok := ve.Fields[tt.wantField]; !ok {
                t.Errorf("want field %q in %v", tt.wantField, ve.Fields)
            }
        })
    }
}

service のテスト(モック)

// internal/service/todo_test.go
type mockTodoRepo struct {
    todos map[int64]*domain.Todo
    next  int64
}

func newMockRepo() *mockTodoRepo {
    return &mockTodoRepo{todos: map[int64]*domain.Todo{}, next: 1}
}

func (m *mockTodoRepo) Create(_ context.Context, t *domain.Todo) error {
    t.ID = m.next
    m.next++
    cp := *t
    m.todos[t.ID] = &cp
    return nil
}

func (m *mockTodoRepo) Get(_ context.Context, userID, id int64) (*domain.Todo, error) {
    t, ok := m.todos[id]
    if !ok || t.UserID != userID {
        return nil, &domain.NotFoundError{Resource: "todo", ID: id}
    }
    cp := *t
    return &cp, nil
}

func (m *mockTodoRepo) Update(_ context.Context, t *domain.Todo) error {
    if _, ok := m.todos[t.ID]; !ok {
        return &domain.NotFoundError{Resource: "todo", ID: t.ID}
    }
    cp := *t
    m.todos[t.ID] = &cp
    return nil
}

func (m *mockTodoRepo) Delete(_ context.Context, userID, id int64) error {
    t, ok := m.todos[id]
    if !ok || t.UserID != userID {
        return &domain.NotFoundError{Resource: "todo", ID: id}
    }
    delete(m.todos, id)
    return nil
}

func (m *mockTodoRepo) List(_ context.Context, f TodoFilter) ([]*domain.Todo, int, error) {
    var out []*domain.Todo
    for _, t := range m.todos {
        if t.UserID == f.UserID {
            out = append(out, t)
        }
    }
    return out, len(out), nil
}

func TestTodoService_Create_NormalizesTags(t *testing.T) {
    svc := NewTodoService(newMockRepo(), nil)

    todo, err := svc.Create(context.Background(), 1, CreateTodoInput{
        Title: "  hello  ",
        Tags:  []string{"Go", " go ", "", "API"},
    })
    if err != nil {
        t.Fatal(err)
    }
    if todo.Title != "hello" {
        t.Errorf("title = %q", todo.Title)
    }
    want := []string{"go", "api"}
    if !slices.Equal(todo.Tags, want) {
        t.Errorf("tags = %v, want %v", todo.Tags, want)
    }
}

func TestTodoService_Update_Partial(t *testing.T) {
    repo := newMockRepo()
    svc := NewTodoService(repo, nil)
    ctx := context.Background()

    created, _ := svc.Create(ctx, 1, CreateTodoInput{Title: "original", Description: "desc"})

    done := true
    updated, err := svc.Update(ctx, 1, created.ID, UpdateTodoInput{Done: &done})
    if err != nil {
        t.Fatal(err)
    }
    if !updated.Done {
        t.Error("done should be true")
    }
    if updated.Title != "original" || updated.Description != "desc" {
        t.Errorf("other fields changed: %+v", updated)
    }
}

func TestTodoService_Update_OtherUsersTodo(t *testing.T) {
    repo := newMockRepo()
    svc := NewTodoService(repo, nil)
    ctx := context.Background()

    created, _ := svc.Create(ctx, 1, CreateTodoInput{Title: "mine"})

    title := "hacked"
    _, err := svc.Update(ctx, 2, created.ID, UpdateTodoInput{Title: &title})
    if !errors.Is(err, domain.ErrNotFound) {
        t.Errorf("want ErrNotFound, got %v", err)
    }
}

handler のテスト(認証込み)

// internal/handler/todo_test.go
func setupTestRouter(t *testing.T) (http.Handler, string) {
    t.Helper()
    tokens := auth.NewTokenService("test-secret-at-least-32-characters-long", time.Hour)
    userRepo := &mockUserRepo{}
    todoRepo := service.NewMockTodoRepo() // service パッケージのテスト用エクスポート

    router := NewRouter(Deps{
        Auth:   service.NewAuthService(userRepo, tokens),
        Todos:  service.NewTodoService(todoRepo, nil),
        Tokens: tokens,
    })
    token, _ := tokens.Issue(1)
    return router, token
}

func doJSON(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
    t.Helper()
    req := httptest.NewRequest(method, path, strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    if token != "" {
        req.Header.Set("Authorization", "Bearer "+token)
    }
    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, req)
    return rec
}

func TestTodos_Unauthorized(t *testing.T) {
    h, _ := setupTestRouter(t)
    rec := doJSON(t, h, "GET", "/api/v1/todos", "", "")
    if rec.Code != http.StatusUnauthorized {
        t.Errorf("status = %d", rec.Code)
    }
}

func TestTodos_CRUD(t *testing.T) {
    h, token := setupTestRouter(t)

    // Create
    rec := doJSON(t, h, "POST", "/api/v1/todos", token, `{"title":"first","tags":["go"]}`)
    if rec.Code != http.StatusCreated {
        t.Fatalf("create: %d %s", rec.Code, rec.Body)
    }
    var created todoResponse
    json.NewDecoder(rec.Body).Decode(&created)

    // Get
    rec = doJSON(t, h, "GET", fmt.Sprintf("/api/v1/todos/%d", created.ID), token, "")
    if rec.Code != http.StatusOK {
        t.Fatalf("get: %d", rec.Code)
    }

    // Patch
    rec = doJSON(t, h, "PATCH", fmt.Sprintf("/api/v1/todos/%d", created.ID), token, `{"done":true}`)
    if rec.Code != http.StatusOK {
        t.Fatalf("patch: %d %s", rec.Code, rec.Body)
    }

    // Validation
    rec = doJSON(t, h, "POST", "/api/v1/todos", token, `{"title":""}`)
    if rec.Code != http.StatusUnprocessableEntity {
        t.Errorf("validation: %d", rec.Code)
    }
    var eb errorBody
    json.NewDecoder(rec.Body).Decode(&eb)
    if _, ok := eb.Fields["title"]; !ok {
        t.Errorf("fields = %v", eb.Fields)
    }

    // Delete
    rec = doJSON(t, h, "DELETE", fmt.Sprintf("/api/v1/todos/%d", created.ID), token, "")
    if rec.Code != http.StatusNoContent {
        t.Errorf("delete: %d", rec.Code)
    }

    // 404 after delete
    rec = doJSON(t, h, "GET", fmt.Sprintf("/api/v1/todos/%d", created.ID), token, "")
    if rec.Code != http.StatusNotFound {
        t.Errorf("after delete: %d", rec.Code)
    }
}

カバレッジの目標

  • domain / service:80% 以上(ロジックの中心)
  • handler:主要なステータスコードを網羅
  • repository:CRUD + NotFound + 制約違反
  • 数値目標より 「バグが出そうな場所にテストがあるか」 を優先
go test -race -coverprofile=cover.out ./... && go tool cover -func=cover.out | tail -1

10. API ドキュメント(OpenAPI)

api/openapi.yaml を書くと、Swagger UI で試せるドキュメントになり、フロントエンドとの契約にもなります。

openapi: 3.0.3
info:
  title: Todo API
  version: 1.0.0
  description: JWT 認証付きの Todo 管理 API

servers:
  - url: http://localhost:8080/api/v1

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Todo:
      type: object
      required: [id, title, done, tags, created_at, updated_at]
      properties:
        id: { type: integer, format: int64, example: 1 }
        title: { type: string, maxLength: 200 }
        description: { type: string, maxLength: 2000 }
        done: { type: boolean }
        due_date: { type: string, format: date, nullable: true }
        tags: { type: array, items: { type: string } }
        overdue: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateTodo:
      type: object
      required: [title]
      properties:
        title: { type: string }
        description: { type: string }
        due_date: { type: string, format: date }
        tags: { type: array, items: { type: string } }

    Error:
      type: object
      properties:
        error: { type: string }
        fields:
          type: object
          additionalProperties: { type: string }

paths:
  /todos:
    get:
      summary: Todo 一覧
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: done, in: query, schema: { type: boolean } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/Todo" } }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Todo 作成
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTodo" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Todo" }
        "422":
          description: Validation error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

Swagger UI を同梱すると GET /docs で閲覧できます。swaggo/http-swagger や、静的な swagger-ui-dist を embed する方法があります。

コードから生成する アプローチ(swaggo/swag のコメント注釈、oapi-codegen でスキーマから Go コードを生成)もあります。中規模以上では oapi-codegen の スキーマファースト が、仕様とコードの乖離を防げるため人気です。


11. README の書き方

README は 最初の 30 秒で読まれる部分 です。以下の順序で書きます。

# Todo API

JWT 認証付きの Todo 管理 REST API。Go 標準ライブラリを中心に、
レイヤードアーキテクチャで実装しています。

[![CI](https://github.com/yourname/todo-api/actions/workflows/ci.yml/badge.svg)](...)
[![Go Report Card](https://goreportcard.com/badge/github.com/yourname/todo-api)](...)

**デモ**: https://todo-api.fly.dev/healthz  **API ドキュメント**: https://todo-api.fly.dev/docs

## 特徴

- JWT(HS256)による認証。パスワードは bcrypt でハッシュ化
- ページネーション、タグ絞り込み、タイトル部分一致検索
- `handler → service → repository → domain` のレイヤード構成
- リポジトリはインターフェースで抽象化し、service をモックでテスト
- 本物の PostgreSQL に対するリポジトリテスト(CI で実行)
- 構造化ログ(`log/slog`)、Graceful Shutdown、ヘルスチェック
- distroless ベースの 15MB コンテナイメージ

## 技術スタック

| 分類 | 採用 |
|---|---|
| 言語 | Go 1.23 |
| ルーター | chi v5 |
| DB | PostgreSQL 16 / database/sql + pgx |
| マイグレーション | golang-migrate |
| 認証 | golang-jwt/jwt v5, x/crypto/bcrypt |
| ログ | log/slog |
| コンテナ | Docker, Docker Compose |
| CI | GitHub Actions(lint, race test, build) |

## クイックスタート

```bash
git clone https://github.com/yourname/todo-api
cd todo-api
cp .env.example .env
docker compose up --build
# ユーザー登録
curl -X POST localhost:8080/api/v1/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"me@example.com","password":"password123"}'
# → {"token":"eyJ...","user_id":1}

# Todo 作成
curl -X POST localhost:8080/api/v1/todos \
  -H 'Authorization: Bearer eyJ...' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Go を学ぶ","tags":["study"],"due_date":"2025-12-31"}'

# 一覧(未完了・タグ絞り込み)
curl 'localhost:8080/api/v1/todos?done=false&tag=study' -H 'Authorization: Bearer eyJ...'

API 一覧

MethodPath説明認証
POST/api/v1/signupユーザー登録–
POST/api/v1/loginログイン(JWT 発行)–
GET/api/v1/me自分の情報✓
GET/api/v1/todos一覧(done, tag, q, limit, offset)✓
POST/api/v1/todos作成✓
GET/api/v1/todos/{id}取得✓
PATCH/api/v1/todos/{id}部分更新✓
DELETE/api/v1/todos/{id}削除✓
GET/healthzヘルスチェック–

詳細は OpenAPI 仕様 を参照。

アーキテクチャ

cmd/server/main.go     依存の組み立てと起動
internal/
  handler/             HTTP ⇄ service の変換。ステータスコードの決定
  service/             ユースケース。バリデーション、正規化、トランザクション
  repository/postgres/ SQL。DB エラー → domain エラーの変換
  domain/              型・不変条件・エラー定義。外部依存なし

依存は常に handler → service → repository の一方向。service は repository の
インターフェースにのみ依存し、DB 実装を知らない。

設計上の判断

  • ORM を使わず素の SQL:クエリの見通しとパフォーマンス制御を優先。
    列の追加に強くするため SELECT * は使わず列名を明示
  • タグは TEXT[] 型:中間テーブルより単純。GIN インデックスで検索も高速
  • JWT はステートレス:失効が必要になった時点でリフレッシュトークン + DB 管理に移行する想定
  • エラーの変換は handler/response.go の 1 か所:ステータスコードの判断を分散させない

開発

make test              # -race 付きユニットテスト
make test-integration  # PostgreSQL が必要
make lint              # golangci-lint
make migrate-up        # マイグレーション適用
make migrate-new       # 新規マイグレーション作成

テスト方針

層方法
domain純粋なテーブル駆動テスト
serviceリポジトリのインメモリモック
repository本物の PostgreSQL(CI の service container)
handlerhttptest + モック service

今後の課題

  • リフレッシュトークン
  • カーソルベースのページネーション
  • OpenTelemetry によるトレーシング
  • Todo の共有(複数ユーザー)

ライセンス

MIT


### README で避けること

- 「これは学習用です」と冒頭で書く(自信のなさが伝わる。書くなら末尾に)
- 動かない手順(必ず **クリーンな環境で** 手順通りに試す)
- 技術の羅列だけで **なぜその選択をしたか** がない
- スクリーンショットのない CLI 出力の説明(curl 例を書けば十分)

---

## 12. Git とコミットの作法

### コミットの粒度

「1 コミット = 1 つの意図」。レビューする人が `git log --oneline` を見て流れが分かる状態にします。

feat: add tag filtering to todo list
feat: add PATCH /todos/{id} for partial update
refactor: extract error mapping into response.go
test: add repository tests for tag search
fix: return 404 instead of 500 when todo belongs to another user
docs: add architecture section to README
chore: add golangci-lint config


### Conventional Commits

接頭辞で種類を示す慣習です。

| 接頭辞 | 意味 |
|---|---|
| `feat` | 機能追加 |
| `fix` | バグ修正 |
| `refactor` | 挙動を変えない整理 |
| `test` | テストの追加・修正 |
| `docs` | ドキュメント |
| `chore` | ビルド、CI、依存更新 |
| `perf` | 性能改善 |

### やってはいけないこと

- `.env`、秘密鍵、`*.pem` をコミット(漏れたら履歴から消しても手遅れ。鍵を再発行)
- `wip`、`fix`、`aaa` だけのメッセージ
- 巨大な 1 コミット(「initial commit」に全部)
- `go.sum` を `.gitignore` に入れる(必要なファイル)

### .gitignore

bin/
tmp/
.out
.env
.env.

!.env.example
.DS_Store
.idea/
.vscode/


### ブランチ運用(個人開発でも)

```bash
git switch -c feat/tag-filter
# 作業
git push -u origin feat/tag-filter
# GitHub で PR を作り、CI が通ったら self-merge

PR を作ると CI の結果と説明文が残り、レビュアーが履歴を追えます。ポートフォリオでも「PR 単位で開発している」ことは好印象です。


13. デプロイして URL を持つ

動く URL があると評価が段違いです。無料枠で十分です。

選択肢

サービス特徴DB
Fly.ioDockerfile そのまま。CLI が快適Fly Postgres または Neon
RenderGitHub 連携で自動デプロイ。UI が分かりやすい無料 PostgreSQL(90 日で期限)
Railway同上。Compose 風の構成内蔵 PostgreSQL
Google Cloud Runコンテナのサーバーレス。無料枠が大きいCloud SQL(有料)または Neon
Neon / Supabaseマネージド PostgreSQL の無料枠–

Fly.io の例

brew install flyctl
fly auth signup
fly launch --no-deploy        # fly.toml が生成される
fly postgres create --name todo-db
fly postgres attach todo-db   # DATABASE_URL が secrets に設定される
fly secrets set JWT_SECRET=$(openssl rand -base64 32)
fly deploy
fly open /healthz

fly.toml

app = "todo-api"
primary_region = "nrt"

[build]

[env] PORT = “8080” LOG_LEVEL = “info”

[http_service]

internal_port = 8080 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 0 [[http_service.checks]] interval = “15s” timeout = “5s” grace_period = “10s” method = “GET” path = “/healthz”

[deploy]

release_command = “/migrate up” # デプロイ前にマイグレーション

release_command を使うには、マイグレーションを実行するバイナリをイメージに含めます。cmd/migrate/main.go を作り、Dockerfile で両方ビルドします。

デプロイ後の確認

curl https://todo-api.fly.dev/healthz
fly logs
fly status

README のデモ URL を更新し、数日おきに動作確認 します(無料枠は停止することがあります)。


14. レビューされる前のセルフチェック

公開前に以下を確認します。

動作

  • クリーンな環境で git clone → docker compose up で起動する
  • README の curl 例がすべて動く
  • make test が -race 付きで通る
  • make lint が警告ゼロ
  • CI がグリーン

コード

  • err を _ で捨てている箇所がない
  • fmt.Println でのデバッグ出力が残っていない
  • TODO / FIXME コメントが放置されていない(あるなら Issue に)
  • 全公開関数・型にドキュメントコメントがある(// TodoService は...)
  • マジックナンバーが定数化されている
  • SQL に文字列連結でユーザー入力を混ぜていない
  • 他ユーザーのリソースにアクセスできない(user_id 条件)

セキュリティ

  • .env や秘密情報がコミットされていない(git log -p | grep -i secret)
  • JWT シークレットがデフォルト値のまま本番に上がっていない
  • エラーレスポンスに内部情報(スタックトレース、SQL)が含まれない
  • リクエストボディサイズが制限されている
  • パスワードの最低長が設定されている

ドキュメント

  • README に「なぜ」が書かれている
  • 動く URL がある
  • コミット履歴が意味のある単位

15. 演習問題

完成させる

  1. AuthService と AuthHandler を本記事の構成に沿って実装する(第5回のコードを層に分ける)。GET /me で自分のメールと登録日を返す
  2. repository のテスト を PostgreSQL で書く。タグ検索($3 = ANY(tags))、部分一致検索、他ユーザーの Todo が見えないことを確認する
  3. cmd/migrate/main.go を作り、golang-migrate をライブラリとして使ってマイグレーションを実行する。Dockerfile に組み込む
  4. OpenAPI を完成 させ、Swagger UI を GET /docs で見られるようにする

品質を上げる

  1. 統計エンドポイント GET /api/v1/todos/stats を追加する。{"total":10,"done":4,"overdue":2,"by_tag":{"go":3,"api":2}} を返す。SQL は COUNT(*) FILTER (WHERE ...) と unnest(tags) を使う
  2. E2E テスト を //go:build e2e タグ付きで書く。Compose で起動した実サーバーに対し、signup → login → CRUD → 他ユーザーで 404 のシナリオを HTTP クライアントで検証する
  3. カバレッジレポート を CI で生成し、PR にコメントとして貼る(codecov または go-test-coverage)
  4. リフレッシュトークン を実装する。refresh_tokens テーブル、POST /refresh、POST /logout。README の「今後の課題」からチェックを外す

公開する

  1. Fly.io または Render にデプロイ し、README にデモ URL とバッジを貼る。/healthz が DB 接続を確認していることを確かめる
  2. セルフレビュー:第 14 節のチェックリストをすべて確認し、GitHub の Issue に「改善したい点」を 3 つ以上書く。これは「自分のコードの弱点を認識している」ことを示す材料になる

次回予告

最終回の第7回では、「動くコード」から「良いコード」へ 進むための知識──Go の慣習と設計原則、パッケージ設計、観測性(メトリクス・トレーシング・pprof)、gRPC、パフォーマンスチューニング、そして学び続けるための情報源──を扱います。

B!
← 一覧へ戻る