シリーズ構成
- Go の基本文法
- CLI ツールを作る
- HTTP サーバーと REST API
- データベース連携
- 実務的な周辺技術
- ポートフォリオを作る(本記事)
- 実践知識を深める
この回の目標は 「採用担当者やレビュアーに見せられる品質の API を完成させ、GitHub に公開し、動く URL を用意する」 ことです。
ここまでの記事で部品はすべて揃っています。この回では 部品を組み合わせる設計 と、他人に伝える技術(README、コミット、ドキュメント)に重点を置きます。
目次
- ポートフォリオの評価基準
- 題材の選び方
- ディレクトリ構成
- レイヤードアーキテクチャ
- ドメインモデルとエラー設計
- 依存注入と main.go
- 各層の実装
- エラーの層間変換
- テスト戦略
- API ドキュメント(OpenAPI)
- README の書き方
- Git とコミットの作法
- デプロイして URL を持つ
- レビューされる前のセルフチェック
- 演習問題
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 は 誰にも依存しない
各層の責務
| 層 | やること | やらないこと |
|---|---|---|
| handler | JSON デコード、バリデーション(形式)、認証情報の取り出し、service 呼び出し、ステータスコード決定 | ビジネスルール、SQL |
| service | ビジネスルール(権限、状態遷移、整合性)、複数 repository の組み合わせ、トランザクション | HTTP の概念(ステータスコード、ヘッダ) |
| repository | SQL の実行、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 | 純粋なユニットテスト | 極速 | バリデーション、ビジネスルール |
| service | repository をモック | 速 | 正規化、部分更新、エラー伝播 |
| repository | 本物の PostgreSQL | 中 | SQL の正しさ、制約、NotFound |
| handler | service をモックまたは実物 + httptest | 速〜中 | ステータス、JSON 形式、認証 |
| E2E | Compose で全部起動 + 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 標準ライブラリを中心に、
レイヤードアーキテクチャで実装しています。
[](...)
[](...)
**デモ**: 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 一覧
| Method | Path | 説明 | 認証 |
|---|---|---|---|
| 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) |
| handler | httptest + モック 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.io | Dockerfile そのまま。CLI が快適 | Fly Postgres または Neon |
| Render | GitHub 連携で自動デプロイ。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. 演習問題
完成させる
- AuthService と AuthHandler を本記事の構成に沿って実装する(第5回のコードを層に分ける)。
GET /meで自分のメールと登録日を返す - repository のテスト を PostgreSQL で書く。タグ検索(
$3 = ANY(tags))、部分一致検索、他ユーザーの Todo が見えないことを確認する cmd/migrate/main.goを作り、golang-migrateをライブラリとして使ってマイグレーションを実行する。Dockerfile に組み込む- OpenAPI を完成 させ、Swagger UI を
GET /docsで見られるようにする
品質を上げる
- 統計エンドポイント
GET /api/v1/todos/statsを追加する。{"total":10,"done":4,"overdue":2,"by_tag":{"go":3,"api":2}}を返す。SQL はCOUNT(*) FILTER (WHERE ...)とunnest(tags)を使う - E2E テスト を
//go:build e2eタグ付きで書く。Compose で起動した実サーバーに対し、signup → login → CRUD → 他ユーザーで 404 のシナリオを HTTP クライアントで検証する - カバレッジレポート を CI で生成し、PR にコメントとして貼る(
codecovまたはgo-test-coverage) - リフレッシュトークン を実装する。
refresh_tokensテーブル、POST /refresh、POST /logout。README の「今後の課題」からチェックを外す
公開する
- Fly.io または Render にデプロイ し、README にデモ URL とバッジを貼る。
/healthzが DB 接続を確認していることを確かめる - セルフレビュー:第 14 節のチェックリストをすべて確認し、GitHub の Issue に「改善したい点」を 3 つ以上書く。これは「自分のコードの弱点を認識している」ことを示す材料になる
次回予告
最終回の第7回では、「動くコード」から「良いコード」へ 進むための知識──Go の慣習と設計原則、パッケージ設計、観測性(メトリクス・トレーシング・pprof)、gRPC、パフォーマンスチューニング、そして学び続けるための情報源──を扱います。
Analyzegear