はじめに
本記事では、SNSアプリケーション「ame:ato」のバックエンド開発を通じて実践した、以下の内容をまとめています。
- レイヤードアーキテクチャの設計
- DI(依存性注入)+ インターフェースによるテストしやすい構造
- Wireを使った自動DI
- Table-Driven Test × GomockによるGoのテスト設計
- GitHub Actions × GHCR × Cloudflare Tunnelを使ったARM VMへのCI/CDパイプライン
リポジトリはこちらです:KanadeSisido/ameato-backend-pub
アプリ概要
ame:atoは、雨の日の窓をコンセプトにした掲示板アプリです。画面の任意の場所をクリックするとメッセージを投稿でき、曇った窓のような画面に文字が浮かびます。投稿した文字は時間経過とともに薄くなり、やがて消えていきます。
技術スタックは以下の通りです。
| カテゴリ | 採用技術 |
|---|---|
| 言語 | Go |
| HTTPフレームワーク | Gin |
| ORM | Gorm |
| DI | Wire |
| DB | MariaDB |
| フロントエンド | Next.js / Tailwind CSS / Motion |
1. アーキテクチャ設計:なぜ層を分けるのか
レイヤードアーキテクチャの基本的な考え方
コードが増えてくると「この処理はどこに書くべきか」が曖昧になりがちです。責務を明確にするために、処理を複数の層(レイヤー)に分割するレイヤードアーキテクチャを採用しました。
基本方針は2つです。
- 各層は1つ下の層だけを呼ぶ:依存の方向を一方向に保ち、変更の影響範囲を限定する
- フレームワーク依存を特定の層に閉じ込める:GinやGormを差し替えても、ビジネスロジックに手を加えなくて済むようにする
本プロジェクトのデータフローは以下の通りです。
Router → Handler → Controller → Repository → DB
各層の役割は次のようになっています。
| 層 | 役割 |
|---|---|
| Router | Ginのルーティング定義。 |
| Handler | GinのContextを受け取り、リクエストのバインドやレスポンス生成(JSON)を担当する+Controllerを呼び出す |
| Controller | アプリ固有のビジネスロジックとバリデーションを担当する。Repositoryを呼び出す |
| Repository | GormによるDB操作(SQL実行・データ取得・保存)を担当する |
HandlerとControllerを分けているのは、GinというフレームワークへのロックインをHandlerに閉じ込めるためです。Controllerはgin.Contextではなくcontext.Contextを受け取ります。
ファイル構成:1ロジック = 1ファイル
今回の設計の特徴は、ロジックごとにStructを分割し、1ファイルで完結させる点にあります。
controller/
├── createMessageController.go
└── getMessageController.go
repository/
├── createMessageRepository.go
└── getMessageRepository.go
handler/
├── postHandler.go
└── getHandler.go
設計の一例として、「層ごとに1つのStructを作り、全メソッドをそこに集める」方法があります。
// 層ごとに単一Structを持つ設計:機能が増えると肥大化していく
// こうなりがち
type UserRepository interface {
CreateUser(ctx context.Context, username string, email string, hashedPassword string) error
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
GetUserById(ctx context.Context, userId string) (*model.User, error)
...
...
...
...
}
// ↑ 全部同じファイルに集まってしまう
// 1ファイルに集中してマージコンフリクトが起きやすくなる
ame:atoでは、1ファイルの中にインターフェース・Struct・メソッドをすべて収めます。
// ame:atoの設計:createMessageController.go
// ① インターフェース
type CreateMessageControllerInterface interface {
CreateMessage(ctx context.Context, message model.CreateMessage) (*model.MessageResponse, error)
}
// ② Struct(依存を外から注入される)
type CreateMessageController struct {
createMessageRepository repository.CreateMessageRepositoryInterface
}
// ③ コンストラクタ(Wireがここを使ってDIコードを生成する)
func NewCreateMessageController(createMessageRepository repository.CreateMessageRepositoryInterface) *CreateMessageController {
return &CreateMessageController{
createMessageRepository: createMessageRepository,
}
}
// ④ メソッド実装
func (c *CreateMessageController) CreateMessage(ctx context.Context, message model.CreateMessage) (*model.MessageResponse, error) {
/* ロジック */
}
この設計のメリットは次の通りです。
- 機能の追加・変更・削除がそのファイルを見るだけで完結する
- 複数人での開発でファイルが衝突しにくい
- 各Structの責務が明確で、テスト単位も小さくなる
2. DI(依存性注入):疎結合とテスト可能性を両立する
DIとは何か
DI(Dependency Injection)とは、あるオブジェクトが依存する別のオブジェクトを、内部で生成するのではなく「外から注入してもらう」設計パターンです。
詳しくは拙著をご参照ください。
DIなしの場合、依存を内部で生成するため、テスト時にDBなしで動かすことができません。
// DIなし:Controllerが依存を自分で生成してしまう(イメージ)
type CreateMessageController struct{}
func (c *CreateMessageController) CreateMessage(...) {
repo := &repository.CreateMessageRepository{db: connectDB()} // 依存を内部で生成
repo.CreateMessage(...)
}
DIありの場合、依存を外から受け取るため、テスト時にモックを渡せます。
// DIあり:依存を外から受け取る(イメージ)
type CreateMessageController struct {
createMessageRepository repository.CreateMessageRepositoryInterface // インターフェース型で持つ
}
func NewCreateMessageController(repo repository.CreateMessageRepositoryInterface) *CreateMessageController {
return &CreateMessageController{createMessageRepository: repo}
}
インターフェースとテスト
Goのインターフェースは実装側が明示的に宣言しなくても自動的にインターフェースを満たせる(暗黙的実装)という特徴があります。
// リポジトリのインターフェース
type CreateMessageRepositoryInterface interface {
CreateMessage(ctx context.Context, message model.CreateMessage) (*model.Message, error)
}
// 本番用の実装(明示的な宣言なしにインターフェースを満たす)
type CreateMessageRepository struct{ db *gorm.DB }
func (r *CreateMessageRepository) CreateMessage(...) (*model.Message, error) { /* DB処理 */ }
// テスト用のモックも同様にインターフェースを満たせる
type MockCreateMessageRepository struct{}
func (m *MockCreateMessageRepository) CreateMessage(...) (*model.Message, error) {
return &model.Message{Content: "mock"}, nil
}
Controllerはインターフェース型にしか依存していないため、テスト時はモックを、本番時は本物のRepositoryを注入するだけで切り替えられます。
WireでDIを自動化する
各層のコンストラクタが揃うと、最終的にMain関数でこれらを組み立てる必要があります。
// 手動DIは層が増えると煩雑になる(イメージ)
func main() {
db := connectDB()
createRepo := repository.NewCreateMessageRepository(db)
getRepo := repository.NewGetMessageRepository(db)
createCtrl := controller.NewCreateMessageController(createRepo)
getCtrl := controller.NewGetMessageController(getRepo)
createHandler := handler.NewPostHandler(createCtrl)
getHandler := handler.NewGetHandler(getCtrl)
// ...
}
Wireを使うと、コンストラクタの一覧を渡すだけで、依存関係を解析して組み立てコードを自動生成してくれます。(アーカイブされてしまいましたが)
// wire.go
//go:build wireinject
// +build wireinject
package main
import (
"ameato/controller"
"ameato/db"
"ameato/handler"
"ameato/repository"
"ameato/router"
"github.com/gin-gonic/gin"
"github.com/google/wire"
)
func InitializeRouter() *gin.Engine {
wire.Build(
db.InitDB,
repository.NewCreateMessageRepository,
repository.NewGetMessageRepository,
controller.NewCreateMessageController,
controller.NewGetMessageController,
handler.NewGetHandler,
handler.NewPostHandler,
router.NewRouter,
wire.Bind(new(handler.GetHandlerInterface), new(*handler.GetHandler)),
wire.Bind(new(handler.PostHandlerInterface), new(*handler.PostHandler)),
wire.Bind(new(controller.CreateMessageControllerInterface), new(*controller.CreateMessageController)),
wire.Bind(new(controller.GetMessageControllerInterface), new(*controller.GetMessageController)),
wire.Bind(new(repository.CreateMessageRepositoryInterface), new(*repository.CreateMessageRepository)),
wire.Bind(new(repository.GetMessageRepositoryInterface), new(*repository.GetMessageRepository)),
)
return nil
}
wire # wire_gen.goが自動生成される
wire_gen.goには先ほど手動で書いていたようなコードが生成されます。依存関係のグラフをWireが解決してくれるので、コンストラクタを追加してもwire.goに追記することでいい感じにしてくれます。また、wire.goを見れば依存関係の全体像が一覧できるというおまけの利点もあります。
3. テスト設計:Table-Driven Test × Gomock
テストの全体方針
本プロジェクトでは、全ロジックに対して約80%のカバレッジを目標にしています。
CIには以下の2種類のテストを用意しています。
- lite-test:DBをSQLiteに置き換えた軽量なテスト。PRごとに実行する
- full-test:本番環境と同様のMariaDBを使ったテスト。mainブランチへのマージ時に実行する
Table-Driven Testとは
Goのテストでよく使われるパターンで、テストケースを構造体のスライスで定義してループで回す方法です。
// イメージ
cases := []struct {
name string
in model.CreateMessage // 入力
want *model.Message // 期待する戻り値
wantErr error // 期待するエラー
}{
{name: "正常投稿", in: ..., want: ..., wantErr: nil},
{name: "空文字はエラー", in: ..., want: nil, wantErr: errors.New("invalid Message Length")},
// ケースを追加するだけで網羅できる
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// テスト処理
})
}
ケースの追加・削除がcasesスライスへの操作で済むため、メンテナンスが楽です。
Gomockでモックを自動生成する
手動でモックを書く場合、インターフェースにメソッドが増えるたびにモックの実装も追加しなければなりません。Gomock(go.uber.org/mock)を使うと、インターフェース定義からモックコードを自動生成できます。
go install go.uber.org/mock/mockgen@latest
mockgen -source=repository/createMessageRepository.go \
-destination=mocks/mock_createMessageRepository.go
テストの全体像
実際のテストコード(一部加筆修正)は下記のとおりです。CreateMessageControllerのテストです。
func TestCreateMessageController(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 自動生成されたモックを使う
repo := mocks.NewMockCreateMessageRepositoryInterface(ctrl)
cases := []struct {
name string
in model.CreateMessage
ret *model.Message
retErr error
expErr bool
}{
// ── 正常系 ──────────────────────────────────────────────────
{
name: "正常投稿(英語)",
in: model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: 0.5}},
ret: &model.Message{Content: "Hello", CreatedAt: time.Now().Unix(), Position: model.Position{X: 0.5, Y: 0.5}},
expErr: false,
},
{
name: "正常投稿(日本語)",
in: model.CreateMessage{Content: "こんにちはこんにちはこんにちはこんにちはこんにちは", Position: model.Position{X: 0.5, Y: 0.5}},
ret: &model.Message{Content: "こんにちはこんにちはこんにちはこんにちはこんにちは", CreatedAt: time.Now().Unix(), Position: model.Position{X: 0.5, Y: 0.5}},
expErr: false,
},
// ── バリデーション:メッセージ長 ─────────────────────────────
{
name: "空文字はエラー",
in: model.CreateMessage{Content: "", Position: model.Position{X: 0.5, Y: 0.5}},
retErr: errors.New("invalid Message Length"),
expErr: true,
},
{
name: "30文字超はエラー",
in: model.CreateMessage{Content: "123456789_123456789_123456789_1", Position: model.Position{X: 0.5, Y: 0.5}},
retErr: errors.New("invalid Message Length"),
expErr: true,
},
// ── バリデーション:座標範囲 ──────────────────────────────────
{
name: "X座標が負はエラー",
in: model.CreateMessage{Content: "Hello", Position: model.Position{X: -0.5, Y: 0.5}},
retErr: errors.New("invalid Position Range"),
expErr: true,
},
{
name: "X座標が1超はエラー",
in: model.CreateMessage{Content: "Hello", Position: model.Position{X: 1.5, Y: 0.5}},
retErr: errors.New("invalid Position Range"),
expErr: true,
},
{
name: "Y座標が負はエラー",
in: model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: -0.5}},
retErr: errors.New("invalid Position Range"),
expErr: true,
},
{
name: "Y座標が1超はエラー",
in: model.CreateMessage{Content: "Hello", Position: model.Position{X: 0.5, Y: 1.5}},
retErr: errors.New("invalid Position Range"),
expErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// ポイント:正常系のみRepositoryが呼ばれることを期待する
// エラー系はControllerのバリデーションで弾かれるため、Repositoryには到達しない
if !tc.expErr {
repo.EXPECT().CreateMessage(gomock.Any(), tc.in).Return(tc.ret, nil)
}
createController := controller.NewCreateMessageController(repo)
msg, err := createController.CreateMessage(context.Background(), tc.in)
if tc.expErr {
if err == nil {
t.Errorf("エラーを期待しましたが、nilでした(期待: %v)", tc.retErr)
} else if err.Error() != tc.retErr.Error() {
t.Errorf("エラーの内容が異なります(期待: %v, 実際: %v)", tc.retErr, err)
}
} else {
if err != nil {
t.Errorf("エラーは期待していませんが、発生しました: %v", err)
}
if msg.Content != tc.ret.Content {
t.Errorf("Contentが異なります(期待: %s, 実際: %s)", tc.ret.Content, msg.Content)
}
}
})
}
}
このテストで検証していることは、大きく2点です。
-
バリデーションが正しく機能するか
空文字・文字数オーバー・範囲外座標などの不正入力に対して、正しいエラーが返るかを確認しています。 -
Repositoryが期待通りに呼ばれるか
if !tc.expErr { repo.EXPECT()... }を記載し、エラーが期待されるケースではRepositoryに到達しないことを宣言しています。もしバリデーションをすり抜けてRepositoryが呼ばれてしまった場合、Gomockがテスト失敗として検出します。
4. 開発環境(Docker Compose × air)
Dockerfile(開発環境)
開発環境では、ファイル変更を検知して自動リビルドするAir(air-verse/air)を使っています。
FROM golang:1.25.5-alpine3.22
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go install github.com/air-verse/air@latest
COPY . .
EXPOSE 8080
CMD ["air", "-c", "air.toml"]
compose.yml(開発環境)
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- .:/app
env_file:
- .env
depends_on:
db:
condition: service_healthy
db:
build:
context: .
dockerfile: Dockerfile.db
restart: always
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
ports:
- "3306:3306"
env_file:
- .env
air.tomlのポーリング設定
自身の環境ではファイルシステムのイベント(inotify)が機能せず、ホットリロードが動きませんでした。poll = trueに設定することで10秒ごとにポーリングして解決しています。
[build]
poll = true
poll_interval = 10000 # ミリ秒。10秒ごとにポーリングする
inotifyが使える環境ではpoll = falseにするとCPU負荷を抑えられます。
5. OpenAPI(swagger)の自動生成(swaggo)
フロントエンドでの型宣言の手間を省くため、HandlerにSwaggerアノテーションコメントを記述し、swaggoでOpenAPIのドキュメントを自動生成しています。
フロントエンドで使用するopenapi-typescriptがswaggoのデフォルト出力であるOpenAPI 2.0に対応していなかったため、swaggo側でOpenAPI 3.1で出力するオプションを指定しています。
swag init --v3.1
生成されたドキュメントをもとにopenapi-typescriptでTypeScriptの型定義を生成することで、楽にAPIの型を宣言できます。
6. CI/CDパイプライン:GitHub Actions × GHCR × ARM VM
デプロイ構成の全体像
GitHub Actions(ubuntu-latest / AMD64)
├─ [lite-test] PR時:SQLiteで軽量テスト
├─ [full-test] mainマージ時:MariaDBで本番(と同等の)テスト
└─ [deploy] 手動トリガー
├─ go build(GOARCH=arm64)
├─ docker build → ghcr.io にpush
└─ SSH(Cloudflare Tunnel経由)でVMにdeploy
Azure VM(ARM64)
└─ docker compose pull & up
├─ backend(distrolessイメージ)
├─ MariaDB
├─ Nginx
└─ Cloudflare Tunnel → インターネット公開
途中までQEMUだったけどやめてクロスビルドに切り替えた
VMがARM64アーキテクチャのため、最初はQEMUを使ってARM向けにDockerイメージをビルドしていました。しかしビルドに10分近くかかるという問題がありました。
参考資料:https://qiita.com/kakinaguru_zo/items/0bc18be7966e5b7e3afe
Goはクロスコンパイルを標準でサポートしているため、ARM64向けバイナリを生成できます。
# AMD64のGitHub Actions上でARM64向けにクロスビルドする
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o main .
本番用Dockerfile(distrolessイメージ)
FROM gcr.io/distroless/static-debian12
WORKDIR /
COPY main /main # GitHub Actionsでクロスビルド済みのバイナリをコピーする
EXPOSE 8000
USER nonroot:nonroot
CMD ["/main"]
distrolessはシェルとかパッケージマネージャーなどを排除したバイナリ実行に特化したイメージです。サイズが極めて小さくalpineよりも小さいです。また、シェルもないので、セキュリティ面でも効果があります。
GitHub Actionsワークフロー(ビルド&デプロイ)
(加筆修正あり)
name: Build and Deploy
on:
workflow_dispatch: # 手動トリガー
env:
IMAGE_NAME: ameato-backend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.5"
# ① ARM64向けにクロスビルドする
- name: Build Bin
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o main .
# ② GHCRへログインしてイメージをpushする
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ③ gitのSHAをタグとして付与する(latestも併せて付与)
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=short
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.prod
push: true
platforms: linux/arm64
tags: ${{ steps.meta.outputs.tags }}
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
# ④ Cloudflare Tunnelを使ってVMにSSH接続する
- name: Install cloudflared
run: |
curl -L --output cloudflared.deb \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
cat >> ~/.ssh/config <<EOF
Host ameato-server
HostName ${{ secrets.SERVER_HOST }}
User ${{ secrets.SERVER_USER }}
IdentityFile ~/.ssh/id_ed25519
ProxyCommand cloudflared access ssh --hostname %h
StrictHostKeyChecking no
EOF
# ⑤ VM上でdocker pullとdocker compose upを実行する
- name: Deploy to Server
run: |
ssh ameato-server << 'EOF'
cd /app/ameato
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
FULL_IMAGE_NAME=$(echo "${{ needs.build-and-push.outputs.image-tag }}" | head -n 1)
export TAG=${FULL_IMAGE_NAME##*:}
export IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
docker compose -f compose.prod.yml pull backend
docker compose -f compose.prod.yml up -d
docker image prune -f
EOF
compose.prod.yml(本番環境)
VMはポートを一切開放しない構成です。外部からのHTTPアクセスはCloudflare Tunnel経由、SSHもCloudflare Tunnel経由で接続します。
services:
backend:
image: ghcr.io/${IMAGE_OWNER}/ameato-backend:${TAG:-latest}
restart: always
env_file: .env
depends_on:
db:
condition: service_healthy
db:
build:
context: .
dockerfile: Dockerfile.db
restart: always
env_file: .env
volumes:
- db_data_prod:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
nginx:
image: nginx:alpine
restart: always
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
tunnel:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
env_file:
- .env
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- nginx
volumes:
db_data_prod:
最初だけセットアップのためにVMの一部ポートを開けて固定IPを介して作業し、DockerComposeが立ち上がったらポートを塞いでIPを解放すればOKです。
CIのテストワークフロー
(加筆修正あり)
name: full test
on:
workflow_dispatch:
workflow_call:
jobs:
full_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install envsubst
run: sudo apt-get update && sudo apt-get install -y gettext
# テスト用の.envを生成する
- name: Setup EnvFile
run: |
cat <<EOF > .env
DB_USERNAME=ameato_ci_user
DB_PASSWORD=ameato_ci_password
DB_NAME=ameato_db
DB_TEST_NAME=ameato_test_db
MYSQL_ROOT_PASSWORD=ameato_root_password
EOF
# envsubstで変数を埋め込んだSQLを生成する
- name: generate init_db.sql
run: |
export $(cat .env | xargs) && envsubst < ./scripts/init_db.sql.template > ./scripts/init_db.sql
# DBコンテナだけ起動する(バックエンドは不要)
- name: Setup Docker Compose
run: docker compose -f compose.yml up -d db
- uses: actions/setup-go@v6.1.0
with:
go-version: "1.25.5"
- name: Run Tests
run: go test -v ./...
7. フロントエンドの実装(Next.js)
Optimistic UI(楽観的UI)
ユーザーの体感速度を上げるため、Optimistic UIを採用しています。メッセージ送信時にサーバーのレスポンスを待たず、ローカルのStateを即座に更新して画面に表示します。これは自作することもできますが、ちょうどuseSWRでデータフェッチを行っていたので、一緒にuseSWRのmutateで実現します。
送信はバックグラウンドで実行され、成功すればそのまま表示継続、失敗した場合(オフライン時など)は再送キュー(unSyncedMessages)に積まれ、オンライン復帰時に自動再送します。
他ユーザーの投稿は定期ポーリング(useMessages hook)で取得し、送信直後のGETリクエストを省くことで不要な通信を減らしています。
また、ロゴマークを押すとmutateで再ロードが行われます。ただし、連打されるとリクエストが増えて困るので、押されて再ロードが開始してから数秒間は再ロードが無効化されます。
UXを高める工夫
入力モードに応じた送信ショートカットの切り替え
日本語IMEの変換確定(Enter)を誤送信として扱わないよう、入力モードに応じてショートカットを動的に変えています。IMEの情報は取れなかったため、最後に打たれた文字が半角か全角かで判定しています。
- 半角入力時:
Enterで送信 - 全角入力時:
Ctrl + Enterで送信
スクロール位置の自動調整
入力フォームが画面外にはみ出す場合に自動スクロールします。onClickのコールバックでscrollToを呼ぶと、ReactのDOM更新と競合してスクロールが止まる問題がありました。setTimeout(fn, 0)でタスクキューに入れ、DOM更新後に実行することで解決しています。
// ReactのDOM更新と競合してスクロールが止まることがある
onClick={() => {
setShowForm(true);
window.scrollTo({ left: targetX, behavior: "smooth" });
}}
// タスクキューに入れてDOM更新後に確実に実行する
onClick={() => {
setShowForm(true);
setTimeout(() => {
window.scrollTo({ left: targetX, behavior: "smooth" });
}, 0);
}}
初見ユーザ向けのTips表示
細かい箇所ですが、サイトが開かれてからタップされずに6秒間経過した場合は「メッセージを描くには画面をタップしてください」の表示が出るようにしてあります。
かなり特殊なUIなので、初見ユーザの興味に応えるためにも重要なTipsだと考えています。
まとめ
ame:atoの開発で実践した内容を整理します。
| テーマ | 内容 |
|---|---|
| 設計 | 1ロジック = 1Struct・1ファイルに分割し、層内の結合度を下げる |
| DI | インターフェース型で依存を持ち、本番・テストで実装を差し替えられる構造にする |
| Wire | コンストラクタを列挙するだけでDIを自動生成し、依存関係をwire.goで一覧できる |
| テスト | Table-Driven Test × Gomockで、楽に高カバレッジのテストをする |
| CI | lite-test(SQLite)とfull-test(MariaDB)の2段階でコストとカバレッジを両立する |
| デプロイ | QEMUをやめてGoのクロスコンパイルでビルド時間を大幅短縮する |
| セキュリティ | distrolessイメージ+Cloudflare TunnelでVMのポートを完全に閉じる |
| フロントエンド | Optimistic UI+オフライン対応でUXを改善する |
リポジトリはこちらです:KanadeSisido/ameato-backend-pub


