2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go + TypeScript構成でチュートリアルWebアプリ作ってみる(2) ~バックエンドにCRUDのAPI作成~

Last updated at Posted at 2025-06-13

初めに

前回の記事

Go + TypeScript構成でチュートリアルWebアプリ作ってみる(1) ~サーバ起動まで~

次回の記事

Go + TypeScript構成でチュートリアルWebアプリ作ってみる(3) ~フロントエンドからAPIを呼び出す~

修正後のディレクトリ構造(フロントエンドは今回は割愛)

クリーンアーキテクチャ構成に変更しました
内側から外側に向かって説明しようと思います

backend/
├── cmd/
│   └── main.go // エントリーポイント
│
├── data/
│   └── todos.json // DB構築は面倒なのでJSONにデータ保存
│
├── internal/
│   ├── delivery/
│   │   └── http/
│   │       └── todo_handler.go // HTTPハンドラー
│   │
│   ├── domain/
│   │   └── todo.go // メインモデルとインターフェース
│   │
│   ├── repository/
│   │   └── todo_repository.go // データアクセス
│   │
│   └── usecase/
│       └── todo_usecase.go // ビジネスロジック
│
└── pkg/
    └── server/
        └── server.go // HTTPサーバー設定

ドメイン層

backend/internal/domain/todo.go
ただのinterface定義クラスです

package domain

// model定義
type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

// repositoryのinterface定義
type TodoRepository interface {
	GetAll() ([]Todo, error)
	Create(todo Todo) (Todo, error)
	Update(todo Todo) (Todo, error)
	Delete(id int) error
}

// usecaseのinterface定義
type TodoUsecase interface {
	GetAll() ([]Todo, error)
	Create(todo Todo) (Todo, error)
	Update(todo Todo) (Todo, error)
	Delete(id int) error
}

リポジトリ層

backend/internal/repository/todo_repository.go
直接のCRUD処理はここに書いています

コード詳細
package repository

import (
	"encoding/json"
	"errors"
	"os"
	"sync"

	"go-typescript-todo/backend/internal/domain"
)

// TodoRepositoryの構造体定義
type JSONFileTodoRepository struct {
	filePath string
	todos    map[int]domain.Todo
	nextID   int
	mu       sync.RWMutex
}

// インスタンス生成処理
func NewJSONFileTodoRepository(filePath string) (*JSONFileTodoRepository, error) {
	repo := &JSONFileTodoRepository{
		filePath: filePath,
		todos:    make(map[int]domain.Todo),
		nextID:   1,
	}

	// jsonファイルがない場合は作成
	if _, err := os.Stat(filePath); os.IsNotExist(err) {
		if err := repo.saveToFile(); err != nil {
			return nil, err
		}
	} else {
		// jsonファイルが存在している場合は読み込み
		if err := repo.loadFromFile(); err != nil {
			return nil, err
		}
	}

	return repo, nil
}

func (r *JSONFileTodoRepository) loadFromFile() error {
	data, err := os.ReadFile(r.filePath)
	if err != nil {
		return err
	}

	if len(data) == 0 {
		return nil
	}

	var todos []domain.Todo
	if err := json.Unmarshal(data, &todos); err != nil {
		return err
	}

	r.todos = make(map[int]domain.Todo)
	for _, todo := range todos {
		r.todos[todo.ID] = todo
		if todo.ID >= r.nextID {
			r.nextID = todo.ID + 1
		}
	}

	return nil
}

// jsonへの保存処理
func (r *JSONFileTodoRepository) saveToFile() error {
	todos := make([]domain.Todo, 0, len(r.todos))
	for _, todo := range r.todos {
		todos = append(todos, todo)
	}

	data, err := json.MarshalIndent(todos, "", "  ")
	if err != nil {
		return err
	}

	return os.WriteFile(r.filePath, data, 0644)
}

// jsonから取得処理
func (r *JSONFileTodoRepository) GetAll() ([]domain.Todo, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	todos := make([]domain.Todo, 0, len(r.todos))
	for _, todo := range r.todos {
		todos = append(todos, todo)
	}
	return todos, nil
}

// 新規レコード作成処理
func (r *JSONFileTodoRepository) Create(todo domain.Todo) (domain.Todo, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	todo.ID = r.nextID
	r.todos[todo.ID] = todo
	r.nextID++

	if err := r.saveToFile(); err != nil {
		return domain.Todo{}, err
	}

	return todo, nil
}

// レコード更新処理
func (r *JSONFileTodoRepository) Update(todo domain.Todo) (domain.Todo, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, exists := r.todos[todo.ID]; !exists {
		return domain.Todo{}, errors.New("todo not found")
	}

	r.todos[todo.ID] = todo

	if err := r.saveToFile(); err != nil {
		return domain.Todo{}, err
	}

	return todo, nil
}

// レコード削除処理
func (r *JSONFileTodoRepository) Delete(id int) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, exists := r.todos[id]; !exists {
		return errors.New("todo not found")
	}

	delete(r.todos, id)

	return r.saveToFile()
}

ユースケース層

backend/internal/usecase/todo_usecase.go
interface実装しているだけ

package usecase

import (
	"go-typescript-todo/backend/internal/domain"
)

type todoUsecase struct {
	todoRepo domain.TodoRepository
}

// todoUsecaseインスタンスの生成
func NewTodoUsecase(repo domain.TodoRepository) domain.TodoUsecase {
	return &todoUsecase{
		todoRepo: repo,
	}
}

func (u *todoUsecase) GetAll() ([]domain.Todo, error) {
	return u.todoRepo.GetAll()
}

func (u *todoUsecase) Create(todo domain.Todo) (domain.Todo, error) {
	return u.todoRepo.Create(todo)
}

func (u *todoUsecase) Update(todo domain.Todo) (domain.Todo, error) {
	return u.todoRepo.Update(todo)
}

func (u *todoUsecase) Delete(id int) error {
	return u.todoRepo.Delete(id)
}


HTTPリクエストの処理

backend/internal/delivery/http/todo_handler.go
送信されてきたリクエストの処理をしています。

コード詳細
package http

import (
	"encoding/json"
	"log"
	"net/http"
	"strconv"

	"go-typescript-todo/backend/internal/domain"
)

// HTTPリクエストを処理する構造体定義
type TodoHandler struct {
	todoUsecase domain.TodoUsecase
}

// インスタンス生成
func NewTodoHandler(usecase domain.TodoUsecase) *TodoHandler {
	return &TodoHandler{
		todoUsecase: usecase,
	}
}

// GetAll処理 /api/todos
func (h *TodoHandler) GetAll(w http.ResponseWriter, r *http.Request) {
	todos, err := h.todoUsecase.GetAll()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	json.NewEncoder(w).Encode(todos)
}

// Create処理 /api/todos
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
	var todo domain.Todo
	if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
		log.Printf("Error decoding request body: %v", err)
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	log.Printf("Received todo: %+v", todo)

	createdTodo, err := h.todoUsecase.Create(todo)
	if err != nil {
		log.Printf("Error creating todo: %v", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	log.Printf("Created todo: %+v", createdTodo)

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	if err := json.NewEncoder(w).Encode(createdTodo); err != nil {
		log.Printf("Error encoding response: %v", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

// Update処理 /api/todos
func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
	var todo domain.Todo
	if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	updatedTodo, err := h.todoUsecase.Update(todo)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	json.NewEncoder(w).Encode(updatedTodo)
}

// Delete処理 /api/todos?id={id}
func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		http.Error(w, "id is required", http.StatusBadRequest)
		return
	}

	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}

	if err := h.todoUsecase.Delete(id); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// 各処理のハンドリング
func (h *TodoHandler) HandleTodos(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		h.GetAll(w, r)
	case http.MethodPost:
		h.Create(w, r)
	case http.MethodPut:
		h.Update(w, r)
	case http.MethodDelete:
		h.Delete(w, r)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

エントリーポイント

backend/cmd/main.go

package main

import (
	"log"
	"net/http"
	"os"
	"path/filepath"

	delivery "go-typescript-todo/backend/internal/delivery/http"
	"go-typescript-todo/backend/internal/repository"
	"go-typescript-todo/backend/internal/usecase"
	"go-typescript-todo/backend/pkg/server"
)

func main() {
	// レコード保存用のディレクトリ作成
	dataDir := filepath.Join("data")
	if err := os.MkdirAll(dataDir, 0755); err != nil {
		log.Fatalf("Failed to create data directory: %v", err)
	}

	// repositoryのインスタンス生成
	todoRepo, err := repository.NewJSONFileTodoRepository(filepath.Join(dataDir, "todos.json"))
	if err != nil {
		log.Fatalf("Failed to initialize repository: %v", err)
	}

	// usecaseにDI
	todoUsecase := usecase.NewTodoUsecase(todoRepo)

	// HTTPにDI
	todoHandler := delivery.NewTodoHandler(todoUsecase)

	// マルチプレクサ生成
	mux := http.NewServeMux()
	mux.HandleFunc("/api/todos", todoHandler.HandleTodos)

	// サーバー起動
	srv := server.NewServer()
	srv.SetupRoutes(mux)

	log.Printf("Server starting on :8080")
	if err := srv.Start(":8080"); err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

サーバー設定

backend/pkg/server/server.go

package server

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

// Server構造体定義
type Server struct {
	router *gin.Engine
}

// Serverインスタンス生成
func NewServer() *Server {
	router := gin.Default()
	return &Server{
		router: router,
	}
}

// セットアップ
func (s *Server) SetupRoutes(mux *http.ServeMux) {
	s.router.Any("/*path", func(c *gin.Context) {
		handler := mux
		handler.ServeHTTP(c.Writer, c.Request)
	})
}

// サーバー起動
func (s *Server) Start(addr string) error {
	log.Printf("Server starting on %s", addr)
	return s.router.Run(addr)
}

API確認

jsonファイルにレコードが存在しないことを確認する

$ cat data/todos.json
[]

画面上でレコードが存在しないことを確認する
http://localhost:8080/api/todosにアクセス

image.png


curlで新しいレコードを作成してみる

$body = @{
    title = "散歩に出かける"
} | ConvertTo-Json -Compress

$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($body)

Invoke-RestMethod -Method Post -Uri "http://localhost:8080/api/todos" -ContentType "application/json; charset=utf-8" -Body $bodyBytes

jsonファイルに追加されていることを確認する

cat data/todos.json
[
  {
    "id": 1,
    "title": "散歩に出かける"
  }
]

画面上でも確認してみる
http://localhost:8080/api/todosにアクセス

image.png

OK!!

各層まとめ

1.Domain Layer (internal/domain/todo.go)

アプリケーションの中心となるモデルとインターフェース
ビジネスロジックの定義
他のレイヤーへの依存がない

2.Repository Layer (internal/repository/todo_repository.go)

データの永続化を担当
JSONファイルを使用した実装
Domain Layerのインターフェースを実装

3.Usecase Layer (internal/usecase/todo_usecase.go)

ビジネスロジックの実装
Repository Layerを使用
Domain Layerのインターフェースを実装

4.Delivery Layer (internal/delivery/http/todo_handler.go)

HTTPリクエストの処理
Usecase Layerを使用
クライアントとの通信を担当

5.Server (pkg/server/server.go)

HTTPサーバーの設定
ルーティングの設定

6.Main (cmd/main.go)

アプリケーションのエントリーポイント
各レイヤーの初期化と接続

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?