【Go初心者】実践的ユーザー付きTODOアプリのAPI実装(おまけFlutter)

目次
- はじめに
- Goの基本文法(一部解説付き)
- struct とは?
- interface とは?
- context.Context とは?
- なぜGoのフレームワークにGinを選んだのか?
- 1. 最も使われているGo Webフレームワークである
- 2. シンプルで学習コストが低い
- 3. net/http互換で、Goの標準を学べる
- 参考:Go framework 比較
- GoとGinのインストール
- DockerでDBの永続化とホットリロード導入
- クリーンアーキテクチャの構成で進めてみる
- 実際にコードを書く
- GORMとMySQLのインストール
- Todoエンティティ
- DB接続レイヤー(infra)
- Repository(DB操作の窓口)
- Usecase(業務ロジック)
- Handler(HTTP/Gin)
- main.go(DIしてルーティング)
- TODOのAPIを完成させて動作確認
- GET
- POST
- PATCH
- DELETE
- ユーザー関連のAPIも作る
- Flutterアプリを構築する
- API Client
- モデル
- AsyncNotifierで状態管理
- 画面
- ルーティング
- 最後に
はじめに
年末年始のまとまった休暇を活用して、これまで本格的に触れてこなかった Go言語の学習に挑戦することにしました。
ただ文法をなぞるだけではなく、実務に近い形で理解したかったため、ユーザー付きTODOアプリのAPIをGoで実装し、Flutterアプリから実際に利用する構成(単にFlutterは書きたかっただけ)を作ってみました。
バックエンドにはGoのWebフレームワークであるGinを採用し、フロントエンドは僕が普段から利用しているFlutter(riverpod)の王道構成で実装します。
この記事では、
- Goの基本文法
- なぜGoを学習対象に選んだのか
- なぜGinを使うのか
- (おまけ)Flutter側でどのようにAPIを利用していくのか
といった点を、要所ごとにまとめていきます。
「GoでAPIを作り、Flutterアプリから利用する」という一連の流れを通して、Go初学者でも実務レベルの感覚を掴める内容を目指します。
Goの基本文法(一部解説付き)
Goのコードをパッとみた時にあまり馴染みのない書き方が多々あったので以下で基本文法をざっと読むことができます。
https://go-tour-jp.appspot.com/list
僕がFlutterエンジニアなのでその視点でわかるようにメモ。
struct とは?
type User struct {
ID uint
Name string
}
// Flutterで書くとこう
class User {
int id;
String name;
}
interface とは?
type UserRepository interface {
Create(...)
}
// Flutterでいう「この型はこの機能を持つ」契約書みたいな
abstract class UserRepository {
Future<void> create(...);
}
context.Context とは?
Flutterの BuildContext に近い。
リクエスト、キャンセル、タイムアウトを処理全体に伝搬させる箱。
なぜGoのフレームワークにGinを選んだのか?
今回、GoでTODOアプリのAPIを作成し、Flutterアプリから利用する構成を学習するにあたり、Webフレームワークには Gin を選びました。
理由は大きく3つあります。
1. 最も使われているGo Webフレームワークである
Ginは、GoのWebフレームワークの中でも圧倒的に利用者が多いライブラリです。
- GitHubスター数:80k以上
- 記事・Qiita・Zennの情報量が非常に多い
- 実務での採用事例も豊富
2. シンプルで学習コストが低い
GinはルーティングやMiddlewareの書き方が直感的で、Go初心者でも「APIがどう作られているか」をすぐに理解できます。
このシンプルさが、学習用途には最適です。
3. net/http互換で、Goの標準を学べる
GinはGoの標準 net/http をラップしているため、
- GoらしいContextの扱い方
- 標準Middleware設計
- 他ライブラリとの親和性
を自然に学ぶことができるようです。
フレームワーク独自仕様ではなく、「Goの正攻法」を身につけられるのは、長期的に見て非常に重要です。
参考:Go framework 比較
色々な比較記事を読んでみましたが、Ginが王道で幅広く使われているようでした。
他のフレームワーク導入は、そのフレームワークが生きる開発であれば導入検討したいですね。
まずは、ほぼ基本と言っても良いGinを扱っていきたいと思います。
GoとGinのインストール
Goプロジェクトを作成し、Ginをインストールします。
% go get -u github.com/gin-gonic/gin
main.goファイルを作成し、GET と POST に対する処理を書き、Postmanなどで動作確認します。
// packageの名前がmainと定義する
// 他のファイルからmainを指定するとこのファイルが実行される
package main
import "github.com/gin-gonic/gin" //GinというWebフレームワークを使用宣言
func main() {
r := gin.Default() // r.Run()とかでサーバーを立てる記述が簡単に書ける
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "ok",
})
})
r.Run()
}

DockerでDBの永続化とホットリロード導入
ここでは開発に欠かせない、ホットリロードとDBの永続化を行います。
以下を参考にしました。
https://zenn.dev/ring_belle/articles/go-docker-air-local
.air.toml、Dockerfileとcomposeを作成することで、コードを変更後に再度Postmanで確認すると変更が即時反映されるようになります。
クリーンアーキテクチャの構成で進めてみる
ここでは
- クリーンアーキテクチャの最小構成
- DI(依存性注入)
- Gin と GORM の責務分離
をGo初心者でも理解できる形で実装しました。
cmd/api/main.go ← エントリーポイント(DIの司令塔)
internal/
├ domain/ ← 業務ルール(エンティティ)
├ infra/ ← 外部接続(DBなど)
├ repository/ ← DB操作の窓口
├ usecase/ ← ビジネスロジック
└ handler/ ← HTTPハンドラ(Gin)
僕はFlutterメインのエンジニアなのでFlutterに置き換えるイメージつきやすく、以下になります。
Flutter | Go |
|---|---|
Widget | handler |
ViewModel / State | usecase |
Repository | repository |
Model | domain |
Dio / API client | infra |
実際にコードを書く
実際に、コードを書いていきます。
GORMとMySQLのインストール
% go get gorm.io/gorm
% go get gorm.io/driver/mysql
Todoエンティティ
internal/domain/todo.go
package domain
import "time"
type Todo struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"type:varchar(255);not null"`
Completed bool `gorm:"not null;default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
要素 | 意味 |
|---|---|
| IDを主キーとして扱う |
| DBレベルで必須 |
| MySQLのTIMESTAMPと対応 |
DB接続レイヤー(infra)
internal/infra/db.go
Docker compose の environment を Go 側で読むための橋渡し。
package infra
import (
"fmt"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type DBConfig struct {
Host string
Port string
User string
Password string
Name string
}
func LoadDBConfigFromEnv() DBConfig {
return DBConfig{
Host: os.Getenv("DB_HOST"),
Port: os.Getenv("DB_PORT"),
User: os.Getenv("DB_USER"),
Password: os.Getenv("DB_PASSWORD"),
Name: os.Getenv("DB_NAME"),
}
}
func NewDB(cfg DBConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
// 接続プール(最低限)
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(10)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
return db, nil
}
Repository(DB操作の窓口)
internal/repository/todo_repository.go
interfaceを挟むのは テストしやすくするため- handler/usecase は GORM を直接知らない(依存しない)
Order("id desc")で新しい順に取得
package repository
import (
"context"
"todo_app_go/internal/domain"
"gorm.io/gorm"
)
type TodoRepository interface {
List(ctx context.Context) ([]domain.Todo, error)
}
type todoRepository struct {
db *gorm.DB
}
func NewTodoRepository(db *gorm.DB) TodoRepository {
return &todoRepository{db: db}
}
func (r *todoRepository) List(ctx context.Context) ([]domain.Todo, error) {
var todos []domain.Todo
if err := r.db.WithContext(ctx).Order("id desc").Find(&todos).Error; err != nil {
return nil, err
}
return todos, nil
}
Usecase(業務ロジック)
internal/usecase/todo_usecase.go
- usecase は「何をするか」を書く層
- 現時点では単に repo を呼ぶだけ(今後、バリデーションや権限などが入る)
package usecase
import (
"context"
"todo_app_go/internal/domain"
"todo_app_go/internal/repository"
)
type TodoUsecase interface {
List(ctx context.Context) ([]domain.Todo, error)
}
type todoUsecase struct {
repo repository.TodoRepository
}
func NewTodoUsecase(repo repository.TodoRepository) TodoUsecase {
return &todoUsecase{repo: repo}
}
func (u *todoUsecase) List(ctx context.Context) ([]domain.Todo, error) {
return u.repo.List(ctx)
}
Handler(HTTP/Gin)
internal/handler/todo_handler.go
- handler は「HTTPの受け口」
- JSONで返す / ステータスコードを決める、などが担当
- DBのことは知らない(usecaseに委譲)
package handler
import (
"net/http"
"todo_app_go/internal/usecase"
"github.com/gin-gonic/gin"
)
type TodoHandler struct {
uc usecase.TodoUsecase
}
func NewTodoHandler(uc usecase.TodoUsecase) *TodoHandler {
return &TodoHandler{uc: uc}
}
func (h *TodoHandler) List(c *gin.Context) {
todos, err := h.uc.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "failed to list todos"})
return
}
c.JSON(http.StatusOK, todos)
}
main.go(DIしてルーティング)
※ main.go を移動します
cmd/api/main.go
main.goは DI の司令塔- “作る順番”がそのまま依存関係を表す
db → repo → usecase → handler
package main
import (
"log"
"todo_app_go/internal/domain"
"todo_app_go/internal/handler"
"todo_app_go/internal/infra"
"todo_app_go/internal/repository"
"todo_app_go/internal/usecase"
"github.com/gin-gonic/gin"
)
func main() {
// --- DB ---
cfg := infra.LoadDBConfigFromEnv()
db, err := infra.NewDB(cfg)
if err != nil {
log.Fatal(err)
}
// 開発用:自動でテーブル作成(後でマイグレーションに置き換え可能)
if err := db.AutoMigrate(&domain.Todo{}); err != nil {
log.Fatal(err)
}
// --- DI(依存性注入) ---
todoRepo := repository.NewTodoRepository(db)
todoUC := usecase.NewTodoUsecase(todoRepo)
todoHandler := handler.NewTodoHandler(todoUC)
// --- Router ---
r := gin.Default()
r.GET("/todos", todoHandler.List)
_ = r.Run(":8080")
}
TODOのAPIを完成させて動作確認
上記のような感じでPOST、PATCH、DELETEも作成しました。
GET
.png?w=1428&fit=max&fm=webp)
POST
.png?w=1428&fit=max&fm=webp)
.png?w=1428&fit=max&fm=webp)
バリデーション確認↓
.png?w=1428&fit=max&fm=webp)
PATCH
.png?w=1428&fit=max&fm=webp)
.png?w=1428&fit=max&fm=webp)
DELETE
.png?w=1428&fit=max&fm=webp)
ユーザー関連のAPIも作る
TODOまでできたので、次に、ユーザーのログイン、登録、ログアウト、削除、TODOとの紐付けまでやってバックエンドを完了にすることとします。
ユーザーの管理はJWTトークンを導入しました。
.png?w=1428&fit=max&fm=webp)
.png?w=1428&fit=max&fm=webp)
Flutterアプリを構築する
実際にAPIを使ってFlutterアプリを作りました。
先に完成系の画像を載せておきます。
次に、Flutterアプリのポイントをざっと解説。テスト容易性、保守性を考えられているおすすめ設計です。
.png?w=1428&fit=max&fm=webp)
API Client
- RiverpodによるDI
- テスト容易性:モックに差し替えるだけでテスト可能
- アプリ全体で共有されるシングルトンなAPI Client
@Riverpod(keepAlive: true)
TodoClient todoClient(Ref ref) {
final dio = ref.read(dioProvider);
return TodoClient(dio);
}
const _path = '/todos';
@RestApi()
abstract class TodoClient {
factory TodoClient(Dio dio, {String? baseUrl}) = _TodoClient;
@GET(_path)
Future<List<Todo>> getTodos();
@POST(_path)
Future<Todo> createTodo(@Body() CreateTodoRequest body);
@PATCH('$_path/{id}')
Future<Todo> updateTodo(
@Path('id') int id,
@Body() UpdateTodoRequest body,
);
@DELETE('$_path/{id}')
Future<void> deleteTodo(@Path('id') int id);
}
モデル
- Immutable
- 型安全
@freezed
abstract class Todo with _$Todo {
@JsonSerializable(fieldRename: FieldRename.snake)
const factory Todo({
required int id,
required String title,
required bool completed,
required DateTime createdAt,
required DateTime updatedAt,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
AsyncNotifierで状態管理
- 画面の構築とロジックが完全分離
- 状態更新がイミュータブル
@riverpod
class TodoNotifier extends _$TodoNotifier {
@override
Future<List<Todo>> build() {
return ref.read(todoRepositoryProvider).fetchTodos();
}
Future<void> addTodo(String title) async {
final repo = ref.read(todoRepositoryProvider);
state = await AsyncValue.guard(() async {
final todo = await repo.createTodo(title);
return [todo, ...(state.value ?? [])];
});
}
Future<void> toggle(Todo todo) async {
final repo = ref.read(todoRepositoryProvider);
state = await AsyncValue.guard(() async {
final updated = await repo.updateTodo(
todo.copyWith(completed: !todo.completed),
);
return [
for (final t in state.value!)
if (t.id == updated.id) updated else t,
];
});
}
Future<void> delete(int id) async {
final repo = ref.read(todoRepositoryProvider);
state = await AsyncValue.guard(() async {
await repo.deleteTodo(id);
return state.value!.where((t) => t.id != id).toList();
});
}
}
画面
- ロジックがNotifierに丸投げできている
- 状態表現が簡潔
class TodoListPage extends ConsumerWidget {
const TodoListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncValue = ref.watch(todoProvider);
return Scaffold(
...
body: asyncValue.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (todos) => todos.isEmpty
? const Center(child: Text('No todos yet'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) {
final todo = todos[i];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todoProvider.notifier).toggle(todo);
},
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
ref.read(todoProvider.notifier).delete(todo.id);
},
),
);
},
),
),
);
}
}
ルーティング
- 認証状態の管理
- ディープリンクの標準対応
final routerProvider = Provider<GoRouter>((ref) {
final authAsyncValue = ref.watch(authProvider);
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: AppPage.home.path,
refreshListenable: AuthListenable(ref),
debugLogDiagnostics: true,
redirect: (context, state) {
final path = state.uri.path;
if (_nonRedirectPaths.contains(path)) {
return null;
}
return authAsyncValue.when(
loading: () => null,
error: (_, __) => null,
data: (authState) => authState.when(
loading: () => null,
unauthenticated: () {
if (path == AppPage.login.path || path == AppPage.register.path) {
return null;
}
return AppPage.home.path;
},
authenticated: (_) {
if (path == AppPage.login.path || path == AppPage.register.path) {
return AppPage.home.path;
}
return null;
},
),
);
},
routes: [
GoRoute(
name: AppPage.login.name,
path: AppPage.login.path,
builder: (_, __) => const LoginPage(),
),
GoRoute(
name: AppPage.register.name,
path: AppPage.register.path,
builder: (_, __) => const RegisterPage(),
),
GoRoute(
name: AppPage.home.name,
path: AppPage.home.path,
builder: (_, __) => const TodoListPage(),
),
],
);
});
最後に
Goを学べたお陰で、何もしない年末で後悔することはなくてよかったです笑
そして、仕事にとらわれないプライベートなコード書くって意外と良いですね。
この感覚久しぶりに良かったので、また時間あったら個人開発でもやってみようかな〜という気持ちになりました。
(あと、NotebookLMを初めて触ったけど結構よくて、色々キャッチアップできた年末年始でした)
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。









