kenya6111のブログ

kenya6111の成長記録。

実務2.5年の振り返り|年収200万円UP&メガベンチャー内定まで

こんにちは!Webエンジニアやっておりますkenyaです。

今回は新卒入社(2023年)〜現在(2025年)までの振り返りをしていこうと思います。

エンジニア歴2.5年のジュニアエンジニアである筆者が、文系学部卒・実務未経験でSES兼受託開発を行う企業に入社し、メガベンチャーから内定を獲得するまでに行った転職活動の過程や、その中で考えたこと・準備したこと・得られた成果について共有していきます。

これからエンジニアを目指す方、今の環境にモヤモヤを感じているジュニアエンジニアの方にとって、少しでもヒントになるような内容になれば嬉しいです。 ぜひ最後までご覧ください。



簡単な経歴

これまでの経歴としては、明治大学の文系学部を卒業後、新卒入社で受託兼SESを行う零細企業に入社しました。

ちなみに在学中はほぼプログラミングはしていませんでした。ProgateでHTML, CSS, javascriptあたりを学習し、自身のプロフィールWebサイトを制作し、ドメイン取得しWeb上に公開するなどしたくらいです。コロナ禍でとんでもなく暇だったのと、ちょうどその頃プログラミングスクールが流行り始めたので乗っかってみた感じです。

https://kenya6111.github.io/kenya-portfolio/


企業選びの軸は、とにかく成長したい、開発経験を積みたい、あたりです。

別で大手SIer群も受けていましたが、開発というよりはBPのマネジメント・顧客折衝・ベンダーコントロール等の業務が多い印象で、あまり自分自身が楽しく働けているイメージが湧かず結局辞退していました。

その結果、受託兼SESの零細企業に内定いただき入社した経緯になります。

入社当時の自分は、Webアプリ開発の経験はゼロ。もちろんチーム開発の経験等もありません。

フレームワークやいろんな技術を駆使してアプリを作り上げる力は、全くと言っていいほどありません。

GitもDockerも聞いたことあるな〜というレベルです。

趣味程度に競技プログラミング問題に熱中しつつ、Javaの基本文法やアルゴリズムには一定の自信がついた程度でした。

漠然と「とりあえず言語自体すらすら書けるようになっとけば、業務もなんとかなるだろう」と本気で思っていました。

2023年 ~ 2024年末

新卒入社(2023)~2024年末までの記録は以下の記事になります。

今見てみると中々苦しんでいるなあと思います。(笑)

kenya6111.hatenablog.com

2025年

2025年は実務にて色々な案件を経験させてもらいました。

  • AIモデルを使ったPoC開発

    • 技術選定:Whisper、VOSK、Google Web Speech API、その他OSSエンジンを候補化・選定
    • 評価設計/実装:Python(FastAPI)で検証APIを構築し、誤認識率・レスポンス速度・モデルサイズを比較
    • UX検証:ブラウザUI(JavaScript/Bootstrap)で音声→テキスト→フォーム自動入力までのユーザ体験を意識し検証・改善
    • 運用目線の整理:オフライン/低帯域時の挙動、ライセンス/コスト整理、必要なハード選定

  • 電子決済系サービスのサーバサイド開発

    • 設計:
      • APIの詳細設計
    • 実装:
      • Java / Spring Boot によるAPI開発
    • テスト:
    • その他:
      • GitHub Copilot活用フローの整備(詳細設計→コード生成→JUnit作成の一連の型化)
      • 新人メンバーの環境構築・実装手順サポート、ドキュメント整備(Confluence)

  • SNSアプリ開発

また、並行してHappinessChainというスクールにも所属しており、GO言語を用いたAPI開発も進めていました。

github.com

こちらは実装途中ですが、転職面接時のアピール材料として記載しておりました。

注力した点としては、機能の実現というよりは、アーキテクチャの理解を深める点と実務想定で実装する点です。

特に転職面接時はバックエンドポジションを主に受けていたため、基本的なアーキテクチャ周りの理解を問われることが多かったです。 なのでこちらの課題で常々検証し1つ1つの実装やアーキテクチャの意図を理解し、トピックごとに面接で話せるようにしておくことを意識していました。 聞かれた質問例は以下のような感じです。(正社員面接、フリーランス含む)

  • 各レイヤーの責務・どんな処理を書くか
  • 各レイヤーの依存の方向
  • interfaceの使い所
  • interfaceがあると何が嬉しいか
  • DIとは
  • 〇〇案件でのレイヤー構成を教えて
  • 単体テストの各レイヤ毎の観点(レイヤ毎の満たすべき内容やカバレッジなど)
  • 依存性の逆転とは
  • usecase層のコードを分譲する際の手段

ちなみに、アーキテクチャ関連は技術書でインプットしておりました。

Amazon.co.jp: ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 eBook : 成瀬 允宣: Kindleストア

ソフトウェアデザイン 2023年6月号 | 田中 ひさてる, 成瀬 允宣, 中村 充志, 奥澤 俊樹, 小林 良太郎, 永野 一馬, 加我 貴志, 小保田 規生, ゴリラ, 日野澤 歓也, 田中 優亮, 平林 純, 國田 圭佑, 小島 優介, 橋本 憲洋, えくろプロテイン, 中村 成陽, 小森 裕介, キャディ株式会社Platformグループ, 可児 亘, 崎原 晴香(H.Saki), 中山 慶祐, 角道 淳平, 中村 勝敏, 武田 隆志, くつなりょうすけ, 上田 隆一, 宮下 悠生, 小澤 昌樹, 弁護士 杉野 直子, 及川 卓也, 鎌田 篤慎, 杉山 貴章, Software Design編集部 |本 | 通販 | Amazon

www.amazon.co.jp

現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法 | 増田 亨 | コンピュータサイエンス | Kindleストア | Amazon

転職活動(2025/10~12)

転職活動は正社員転職の他にもフリーランスも同時並行で進めていました。

職務経歴書,スキルシート

正社員転職では職務経歴書フリーランスではスキルシートが必要となるのでまずそちらの記載を進めました。

実績やエピソードは基本的に「課題・アクション・結果」の3段構成で整理していました。

基本AIとの壁打ち、エージェントやエンジニアにレビューしてもらう形で仕上げました。


転職媒体について

使った転職媒体は以下です。

基本的にFindyとテックビズを主軸に転職活動を進めていました

両媒体とも知人からの紹介で使ってみた格好です。

Findyを利用して良いなと感じた点は、面談の初期に求職者のマインド面や価値観をかなり深ぼってくださり、それに合うカルチャーの企業を提示してくださったことです。今回内定承諾した企業もこちらの紹介経由です。自己応募する場合は限られた尺の中で面接官に「価値基準」や「いかに御社のカルチャーに自分がマッチするか」を伝える必要があるのでその点で選考が非常にスムーズに進んだと思います。

またフリーランス面談では「求める技術があるかどうか」の観点が強く技術質問がほとんどなので、こちらで技術質問にはある程度対応できるようになったか思います。


カジュアル面談

カジュアル面談はどの企業様も「選考要素はありません」とお話ししてくださいますが、個人的にバリバリ選考されていると感じました。 何なら面談を録画する企業様も多々ありましたので、流石に選考してるんだろうなという感じです。

大体のカジュアル面談は以下の流れでした

  1. 自己紹介
  2. 企業紹介
  3. 候補者への質問
  4. 質疑応答
  5. まとめと次のステップの確認

「3.求職者への質問」では以下のような質問を受けました。

  • 簡単な自己紹介
  • 転職意欲について(転職活動中なのか、半年後をめどなのか等)
  • 転職を考えたきっかけ
  • 転職軸
  • 今後のキャリア展望
  • 具体的な業務内容や使われている技術
  • やっていきたいこと

「3.求職者への質問」でも深ぼって聞かれるため、ほぼ面接です。

またカジュアル面談後は礼文は送るようにしていました。 「本日は、面談ありがとうございました」などと簡単に伝えるだけでも、企業様から感謝のメッセージが返ってくるので印象は良くなるのではないかと思います。


選考対策

面接では経歴書記載の案件についての深掘りが主なので、それぞれの案件でアピールできるエピソードを細かく徹底的に言語化しました。

エピソードを話すにあたり以下の姿勢が伝わるように話しました。

  • 自発的自主的に課題に気づき提案し改善しているか
  • 個人だけでなくチーム全体まで視野を持てているか
  • オーナーシップを持ってリードしてきたか
  • 1つ1つの行動や実装に判断理由があるかどうか(考えて意思決定しているか)

また私の癖として、整理できてないエピソードを話す際に、結論ファーストで話せずグダグダと長びく傾向があるので、そういう意味でも経歴の言語化は徹底していました。面倒も面倒ですが、これをすることで、連日の面接ストレス負荷を低減できたと思います。

あとは回答できなかった技術系質問をリストアップし、それぞれの分野に関して技術書やAIを使って理解を深めました。

アーキテクチャ関連の質問は特に、理解がふわっとしている部分が多かったので、都度コードに起こしてみて「何が嬉しいか」「なぜそう設計するのか」などひたすら言語化し、理解を深めていました。


選考結果

応募:10社

一次選考:5社

最終選考:2社

内定:1社

最終的にHRTech系のメガベンチャー様よりバックエンドポジションで内定いただけました。

今振り返って思うこと(反省・感想)

  • 毎月経歴書はアップデート

    • 今回の転職活動では、3年分のエピソードと実績をまとめて言語化しました。1年前の案件ですらざっくりとしかタスク内容を覚えておらず、中々言語化に時間がかかりました。今後は「課題→アクション→結果」のフレームワークでエピソードを都度書き留めておきたいなと感じました。次の転職活動で初動が遅れなくて済みますし、ある種下心を持ってより良いエピソードにするために、企業やチームにもっと貢献しようと意識でき、日々の業務の質もより上がったのではないかと思います。
  • 社外の人と関わる

    • 現職では人手不足が深刻であり、1人が複数案件を並行で掛け持ち(2案件ほど)、「人員最適化」と称し半年〜1年ほどで案件を強制移動が発生していました。その結果「一貫した技術が身に付きづらい」「ドメインや仕様の理解がリセット」「仕様を知らなくてもできる浅いタスクになりがち」などの課題がありました。かつレガシーな案件も多い。こういった点を本気で課題に感じ始めたのも、プライベートでスクールやイベントに参加し、社外のエンジニア(Web系,SIer,SES)とお話しする機会を活用していたことがきっかけです。上記の課題を話すと大体の方には驚かれたので、自社が普通ではないことに気づきました。所属会社以外にエンジニアと交流する場を持っておくことで自身のキャリアに対して比較検討できるか思います。特に新卒入社の方は、まだ経験が少ない故に価値観がその会社のものに染まりやすいと思います。転職活動の時になって自社の違和感に気づいては遅すぎます。ぜひ日頃から社外の人と接点を持っておくことをお勧めします。

終わりに

2025年はひたすら積み上げる年でした。

業務ではひたすらブラックに働き、プライベートではスクールでの学習を継続し、転職成功できました。

2026年は引き続きバックエンド領域の知見を深め、インフラ領域にも挑戦していきます。 また、リード経験を積んでいきたいと思います。 表立ってリードの役割が難しくとも、テックリードやチームメンバーが、楽に仕事しやすいと思ってもらえるようGiveする姿勢は徹底していきたいと思います。 相手に与えて与えて与えて、この人自分のために一生懸命やってくれるよね・自分のことよく考えてくれるよな・現場がよりよくなるように行動してくれるよな と思ってもらえるよう動いていきます。

最後まで読んでいただき、ありがとうございました。

【GO】【sqlc】sqlc.embedが外部結合時に生まれるNULLカラムに非対応だった件

タイトル通り、sqlcにまさかの対応できていないバグがあるようだったのでメモ。

事件は一覧取得APIを実装していた時のこと。

以下3テーブルからLEFT JOINでツイートの一覧取得を実装していました。

devdb=# select * from tweet;
 id | user_id |              content              | reply_to_id |         created_at         
----+---------+-----------------------------------+-------------+----------------------------
  2 | user1   | こんにちは!これはテストツイート1 |             | 2025-11-26 14:22:07.305993
  3 | user1   | 今日はいい天気ですね              |             | 2025-11-26 14:22:07.305993
  4 | user2   | user1さんに返信します             |           1 | 2025-11-26 14:22:07.305993
  5 | user2   | 写真をアップします!              |             | 2025-11-26 14:22:07.305993
  6 | user3   | user3のツイートです               |             | 2025-11-26 14:22:07.305993
  7 | user3   | ランチしてきた                    |             | 2025-11-26 14:22:07.305993
(6 rows)

devdb=# select * from tweet_images;
 id | tweet_id |             image_url              |         created_at         
----+----------+------------------------------------+----------------------------
  3 |        4 | https://example.com/img/photo1.jpg | 2025-11-26 14:22:12.320571
  4 |        4 | https://example.com/img/photo2.jpg | 2025-11-26 14:22:12.320571
  5 |        6 | https://example.com/img/lunch.jpg  | 2025-11-26 14:22:12.320571
(3 rows)

devdb=# select * from follows;
 follower_id | followed_id |         created_at         
-------------+-------------+----------------------------
 user1       | user2       | 2025-11-26 14:22:16.548981
 user1       | user3       | 2025-11-26 14:22:16.548981
 user2       | user1       | 2025-11-26 14:22:16.548981
 user3       | user1       | 2025-11-26 14:22:16.548981
(4 rows)

実際のSQLはこちら

devdb=# 
select 
    *
from tweet t 
left join tweet_images ti 
    on t.id = ti.tweet_id 
inner join follows f 
    on f.followed_id = t.user_id 
where ('user1' = '' OR f.follower_id = 'user1') 
    and t.reply_to_id IS NULL;
 
 id | user_id |       content        | reply_to_id |         created_at         | id | tweet_id |             image_url             |         created_at         | follower_id | followed_id |         created_at         
----+---------+----------------------+-------------+----------------------------+----+----------+-----------------------------------+----------------------------+-------------+-------------+----------------------------
  6 | user3   | user3のツイートです  |             | 2025-11-26 14:22:07.305993 |  5 |        6 | https://example.com/img/lunch.jpg | 2025-11-26 14:22:12.320571 | user1       | user3       | 2025-11-26 14:22:16.548981
  5 | user2   | 写真をアップします! |             | 2025-11-26 14:22:07.305993 |    |          |                                   |                            | user1       | user2       | 2025-11-26 14:22:16.548981
  7 | user3   | ランチしてきた       |             | 2025-11-26 14:22:07.305993 |    |          |                                   |                            | user1       | user3       | 2025-11-26 14:22:16.548981
(3 rows)

JOINした結果、ツイートに紐づく画像(tweet_images)がない場合は、もちろんNULLが入ります。

ここまでは良かったのです。

今回はsqlcを使っていたので、上記のSQLを元にquery.sqlを作り

query.sql

- name: TweetAndImages :many
SELECT sqlc.embed(tweet), sqlc.embed(tweet_images)
FROM tweet
LEFT JOIN tweet_images ON tweet_images.tweet_id = tweet.id
LEFT JOIN follows ON follows.followed_id = tweet.user_id
WHERE($3 = '' OR follows.follower_id = $3)
 AND tweet.reply_to_id IS NULL
LIMIT $1 OFFSET $2;

query.sqlを元に「sqlc generate」コマンドでGOのソースを生成していました。

それがこちら。

type Tweet struct {
    ID        int32
    UserID    string
    Content   string
    ReplyToID pgtype.Int4
    CreatedAt pgtype.Timestamp
}

type TweetImage struct {
    ID        int32
    TweetID   int32
    ImageUrl  string
    CreatedAt pgtype.Timestamp
}


const tweetAndImages = `-- name: TweetAndImages :many
SELECT tweet.id, tweet.user_id, tweet.content, tweet.reply_to_id, tweet.created_at, tweet_images.id, tweet_images.tweet_id, tweet_images.image_url, tweet_images.created_at
FROM tweet
LEFT JOIN tweet_images ON tweet_images.tweet_id = tweet.id
LEFT JOIN follows ON follows.followed_id = tweet.user_id
WHERE($3 = '' OR follows.follower_id = $3)
 AND tweet.reply_to_id IS NULL
LIMIT $1 OFFSET $2
`

type TweetAndImagesParams struct {
    Limit   int32
    Offset  int32
    Column3 interface{}
}

type TweetAndImagesRow struct {
    Tweet      Tweet
    TweetImage TweetImage
}

func (q *Queries) TweetAndImages(ctx context.Context, arg TweetAndImagesParams) ([]TweetAndImagesRow, error) {
    rows, err := q.db.Query(ctx, tweetAndImages, arg.Limit, arg.Offset, arg.Column3)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var items []TweetAndImagesRow
    for rows.Next() {
        var i TweetAndImagesRow
        if err := rows.Scan(
            &i.Tweet.ID,
            &i.Tweet.UserID,
            &i.Tweet.Content,
            &i.Tweet.ReplyToID,
            &i.Tweet.CreatedAt,
            &i.TweetImage.ID,
            &i.TweetImage.TweetID,
            &i.TweetImage.ImageUrl,
            &i.TweetImage.CreatedAt,
        ); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}

SELECT sqlc.embed(tweet), sqlc.embed(tweet_images)というsqlc独自の文法を書くことで

GO側の構造体であるTweetAndImagesRowの構造体の中にembedした塊がそれぞれ入ってくれるわけです。

なんて綺麗・・・と思いつついざAPIの動作確認してみたのですが、

twitter-clone-api  | 2025/11/26 17:03:00 tweet_repository.go:47: can't scan into dest[5] (col: id): cannot scan NULL into *int32
twitter-clone-api  | 2025/11/26 17:03:00 errorHandler.go:34: can't scan into dest[5] (col: id): cannot scan NULL into *int32

なぜかNULLが弾かれエラーで落ちているのです。

???でした。

該当のテーブルはこちら

CREATE TABLE IF NOT EXISTS tweet_images (
    id SERIAL PRIMARY KEY,
    tweet_id INTEGER NOT NULL,
    image_url TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

このDDLを元にsqlcで構造体を作っており、それがこちら(さっき貼りましたが一応)

type TweetImage struct {
    ID        int32
    TweetID   int32
    ImageUrl  string
    CreatedAt pgtype.Timestamp
}

はい。DDLでNOT NULL制約つけてるのでもちろん構造体もNOT NULLのデータ型が適用されます。

(ちなみにNULL許容のデータ型は↑にもある通り、pgtype~から始まる型です。こちらにNULL許容カラムは変換されます)

ですが、

先ほどのLEFTJOINの結果を思い出してください、

devdb=# select * from tweet t left join tweet_images ti on t.id = ti.tweet_id inner join follows f on f.followed_id = t.user_id where ('user1' = '' OR f.follower_id = 'user1') and t.reply_to_id IS NULL; 
 id | user_id |       content        | reply_to_id |         created_at         | id | tweet_id |             image_url             |         created_at         | follower_id | followed_id |         created_at         
----+---------+----------------------+-------------+----------------------------+----+----------+-----------------------------------+----------------------------+-------------+-------------+----------------------------
  6 | user3   | user3のツイートです  |             | 2025-11-26 14:22:07.305993 |  5 |        6 | https://example.com/img/lunch.jpg | 2025-11-26 14:22:12.320571 | user1       | user3       | 2025-11-26 14:22:16.548981
  5 | user2   | 写真をアップします! |             | 2025-11-26 14:22:07.305993 |    |          |                                   |                            | user1       | user2       | 2025-11-26 14:22:16.548981
  7 | user3   | ランチしてきた       |             | 2025-11-26 14:22:07.305993 |    |          |                                   |                            | user1       | user3       | 2025-11-26 14:22:16.548981
(3 rows)

devdb=# 

tweet_imageテーブルのカラムはNULLになってしまうのです。

と言うことはDBから取得したNULL値を先ほどの構造体にScanしようとして、NULLを受け付けませんよと、エラーが出ていたわけです。

ここで修正案として思いついたのが3案。

① embedやめて個別カラム&coalesce

② Go側で nullable 型で受ける

③ structを pointer にする

まず③について。

これは単純いstringやらintやらをポインタ型にしてnull受け取り可能にしてしまいや!と言う案ですが、

DBからNULLが来うる構造体の要素に1つ1つ「*」をつけていくのはめちゃめちゃソースが汚れて気持ち悪いので

真っ先に除外しました。ポインタ型にすることで値を取り出す際も「*name」のように常にアスタが変数の前に付き纏うので気持ち悪いです。

次に①

こちらはsqlc.embedを使うのをやめてcoalease()でnullの場合は空文字や0など入れて対策しよう作戦です。

これはどうでしょうか。

具体的には、まずembedをやめるにあたり、SQLを以下のように直してフラットにします

SELECT 
    t.id,
    t.user_id,
    t.content,
    t.reply_to_id,
    t.created_at,
    COALESCE(ti.id, 0) AS image_id,
    COALESCE(ti.tweet_id, 0) AS image_tweet_id,
    COALESCE(ti.image_url, '') AS image_url,
    COALESCE(ti.created_at, '1970-01-01') AS image_created_at

構造体も分けずに全部一緒くたに1つの構造体に入れるようにしないといけません

TweetAndImagesRow{
    ID,
    UserID,
    Content,
    ReplyToID,
    CreatedAt,
    ImageID,
    ImageTweetID,
    ImageUrl,
    ImageCreatedAt
}

ただこれだと完全に変更に弱いソースになってしまうんですね。

例えばカラムが追加された時、間違いなく上記のSQLにカラムを手動で追加しないといけませんね。

そして構造体の方にも手動で追記しないといけません。 毎回めんどいし、間違えますよねこれ。

最悪なのがJOINを増やした時です。

SQLはもちろん、構造体にも同じようにカラムを手動追加が必要です。。

何より1つの構造体の中に複数テーブルのカラムが一緒くたに入れられているのでもうどっからどこがどのテーブルのカラムが見当がつきません。

そもそも sqlcで生成した構造体を破壊してるのでもはやsqlcを使ってる意味がなくなりつつあります。


そんな理由なので embedは使いたいわけです。

なんでかと言うと、変更に強いからです。

カラムが追加にあった場合であっても、基本DDLに追加してsqlc generateするのみで、query.sqlやGOのソースを修正する必要がありません。

クールですね。

と言うわけでScan前後でsql.NullStringなど利用してエラーを一旦回避するようにしました。

以下が修正案です。

func (q *Queries) TweetAndImages(ctx context.Context, arg TweetAndImagesParams) ([]TweetAndImagesRow, error) {
    rows, err := q.db.Query(ctx, tweetAndImages, arg.Limit, arg.Offset, arg.Column3)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []TweetAndImagesRow

    for rows.Next() {
        var i TweetAndImagesRow

                 //----------------------------------------------↓ここから
        // NULL があり得るので nullable で受ける
        var (
            imgID        sql.NullInt32
            imgTweetID   sql.NullInt32
            imgURL       sql.NullString
            imgCreatedAt pq.NullTime // pgtype.Timestamp の場合は pgtype.Timestamp でも可
        )
                 //----------------------------------------------↑ここまで追加

        // Tweet のフィールドはそのまま
        if err := rows.Scan(
            &i.Tweet.ID,
            &i.Tweet.UserID,
            &i.Tweet.Content,
            &i.Tweet.ReplyToID,
            &i.Tweet.CreatedAt,

                 //----------------------------------------------↓ここから
            &imgID,
            &imgTweetID,
            &imgURL,
            &imgCreatedAt,
                 //----------------------------------------------↑ここまで追加
        ); err != nil {
            return nil, err
        }

                 //----------------------------------------------↓ここから
        // NULL なら TweetImage は空 struct のまま
        if imgID.Valid {
            i.TweetImage.ID = imgID.Int32
        }

        if imgTweetID.Valid {
            i.TweetImage.TweetID = imgTweetID.Int32
        }

        if imgURL.Valid {
            i.TweetImage.ImageUrl = imgURL.String
        }

        if imgCreatedAt.Valid {
            // TweetImage.CreatedAt が pgtype.Timestamp の場合
            i.TweetImage.CreatedAt = pgtype.Timestamp{
                Time:  imgCreatedAt.Time,
                Valid: true,
            }
        }
                 //----------------------------------------------↑ここまで追加

        items = append(items, i)
    }

    if err := rows.Err(); err != nil {
        return nil, err
    }

    return items, nil
}

まあでも上記のファイルもsqlc generateで生成したファイルに手を加えているので、正直ダメダメだとは思います。

次誰かがsqlc generateした際に差分出てしまうので、よくないですよね。僕なら混乱しちゃう。

てな感じで、sqlcのembedがLEFT JOIN時のNULLカラムに対応できてない問題対応に関してでした!

ここまでお読みいただきありがとうございました。

【GO】【sqlc】LEFT JOINの取得結果を駆動テーブルの主キーごとにグルーピングしたい

tweetテーブル

tweet_imageテーブル

がある

devdb=# select * from tweet;
 id | user_id  |  content   | reply_to_id |         created_at         
----+----------+------------+-------------+----------------------------
  1 | ユーザID | コンテント |          11 | 2025-11-25 12:29:43.195435
(1 row)

devdb=# select * from tweet_images;
 id | tweet_id |                             image_url                              |         created_at         
----+----------+--------------------------------------------------------------------+----------------------------
  1 |        1 | images/2025/11/25/2c297039-2a79-4507-a836-faee03231cf7IMG_4687.PNG | 2025-11-25 12:29:43.195435
  2 |        1 | images/2025/11/25/ddb9cfcb-9941-4eaf-9b8d-56c20f230c29IMG_4690.PNG | 2025-11-25 12:29:43.195435
(2 rows)

こちらLEFT JOIN で取得すると2レコード取得できる

devdb=# select * from tweet t left join tweet_images ti on t.id = ti.tweet_id; 
 id | user_id  |  content   | reply_to_id |         created_at         | id | tweet_id |                             image_url                              |         created_at         
----+----------+------------+-------------+----------------------------+----+----------+--------------------------------------------------------------------+----------------------------
  1 | ユーザID | コンテント |          11 | 2025-11-25 12:29:43.195435 |  1 |        1 | images/2025/11/25/2c297039-2a79-4507-a836-faee03231cf7IMG_4687.PNG | 2025-11-25 12:29:43.195435
  1 | ユーザID | コンテント |          11 | 2025-11-25 12:29:43.195435 |  2 |        1 | images/2025/11/25/ddb9cfcb-9941-4eaf-9b8d-56c20f230c29IMG_4690.PNG | 2025-11-25 12:29:43.195435
(2 rows)

ただサーバサイド(GO)のTweetの構造体は以下のようになっている。

type Tweet struct {
    ID        int
    UserID    string
    Content   string
    Images    []TweetImage
    ReplyToID *int
}

type TweetImage struct {
    ID       int
    TweetID  int
    ImageUrl string
}

Tweetの構造体1つにTweetImageが複数くっついている構造。

この形になんとかグルーピングしたい

今回の個人開発ではsqlcを用いているので、要は

「sqlcで生成した構造体」→ 「domain層の構造体」に変換したいのです

sqlcで生成された Tweet, TweetImage, それからJOIN用の構造体は以下の通り

※sqlcではJOINをする場合、くっつけるテーブルの要素全てが1つの構造体にぶち込まれて生成されます(以下で言うところのTweetAndImagesRow)


type Tweet struct {
    ID        int32
    UserID    string
    Content   string
    ReplyToID pgtype.Int4
    CreatedAt pgtype.Timestamp
}

type TweetImage struct {
    ID        int32
    TweetID   int32
    ImageUrl  string
    CreatedAt pgtype.Timestamp
}

type TweetAndImagesRow struct {
    Tweet      Tweet
    TweetImage TweetImage
}

イメージとしては、例えばツイート一覧取得APIであればrepository層の返り値が「TweetAndImagesRowのスライス」となるといった具体です

以下実際の実装部分です。

func (tr *TweetRepository) GetList(ctx context.Context, limit int, offset int) ([]domain.Tweet, error) {
    q := tr.client.Querier(ctx)

    param := db.TweetAndImagesParams{
        Limit:  int32(limit),
        Offset: int32(offset),
    }
    tweets, err := q.TweetAndImages(ctx, param)
    if err != nil {
        log.Println(err)
        return nil, err
    }

    res := toTweetDomainWithImage(tweets)

    return res, nil
}

上記のq.TweetAndImages(ctx, param)の返り値が「TweetAndImagesRow」となるので、

そちらを「Tweet」に変換してusecase層に返したいわけです。

その際にツイートごとにグルーピングして返したいわけですね。

そのグルーピングの処理のメモ書きになります。

以下実装内容

func toTweetDomainWithImage(in []TweetAndImagesRow) []TweetDomain {
    tweetMap := make(map[int32]*TweetDomain)

    for _, row := range in {
        tweet := toTweetDomain(&row)
        image := toTweetImageDomain(&row)

        // まだTweetDomainが存在しないなら新規作成
        if _, ok := tweetMap[row.Tweet.ID]; !ok {
            tweet.Images = []TweetImageDomain{}
            tweetMap[row.Tweet.ID] = &tweet
        }

        // 画像が存在する(ID != 0)の場合のみ追加
        if image.ID != 0 {
            tweetMap[row.Tweet.ID].Images = append(tweetMap[row.Tweet.ID].Images, image)
        }
    }

    // map → slice に変換
    result := make([]TweetDomain, 0, len(tweetMap))
    for _, t := range tweetMap {
        result = append(result, *t)
    }

    return result
}

とりあえずツイートごとにまとめるにあたりmapを使ってみました。

これで単純なブルートフォース作戦よりはましなはず。。(アルゴリズムあまりわからない。。。勉強しなきゃ。。)

試しにグルーピング後のデータを出力

[
  {1 user1 content [{1 1 urlurl} {2 1 urlurl} {3 1 urlurl}] 0x1400000e278},
  {2 user1 content [{4 1 urlurl} {5 1 urlurl}] 0x1400000e290}
]

いい感じにツイートごとに分類&ツイートごとに紐づく画像がグルーピングされていますね!

以上です!

【GO】ややこしい「*」「&」について

GOのコードを読んでいると頻繁に出てくる「*」「&」の記述。 こちら曖昧だったので整理してみました。

「&」

仮にTweetという構造体があるとします。

&Tweet{} と Tweet{} の違いはというと

「*」

こちらが少々混乱を招きやすい。

  • 変数に対して「*val」などと書いた場合→これは「ポインタが指す中身を取り出す」の意味。
func main() {

    var p1 *int 
    fmt.Println(p1)

    b := 99
    p1 = &b

    fmt.Println(p1)// 0x14000102028

    fmt.Println(*p1) // 99 ← ポインタ型の中身を取り出している
}
  • 対して、変数宣言時やメソッドの引数部分で変数に付与した 「*」 は「ポインタ型として受け取るよ」の意味になる
    • →これが変化球すぎて普通にソース読んでいると混乱してしまいました。
func main (){
        // 変数宣言時に使う「*」
    var p1 *int. // p1というint変数をpoint型で定義
    fmt.Println(p1). // 本来はアドレスが出るがまだ何も代入してないのでん「nil」が出力される

    b := 10
    p1 = &b // aのメモリアドレスに変数bのメモリアドレスが入った

    fmt.Println(p1) // 「0x14000098028」のような変数bのメモリアドレスが出力される
}


type Person struct {
    name string
}
// 上述の通り、Person型で値ではなく、ポインタ型で受け取るという意味。返り値もポインタ型。
func test1(obj *Person) *Person {
    return &Person{
        name: obj.name + "testsets",
    }
}



package main

import "fmt"

type Person struct {
    name string
}


func main() {
    p := Person{name: "kenya"}
    pp := test1(&p)
    fmt.Println(pp.name)
}

func test1(obj *Person) *Person {
    return &Person{
        name: obj.name + "testsets",
    }
}

このように引数の変数に「*」を付与すると、その引数はポインタ型になります。

上記ですと、test1のメソッドは、Person構造体のポインタを受け取り、返り血としてPersonのポインタを返すという意味になる。

ま他、冒頭に出てきた「&」をreturn部分で使っていますが、これは&PersonでPersonのポインタを取得しリターンしています。

ちなみにスライスは参照型でない。

func NewTweetImage(tweetId int, imageUrls []string) ([]TweetImage, error) {
    var result []TweetImage
    for _, url := range imageUrls {
        // ImageUrl のドメインルール_
        if utf8.RuneCountInString(url) < imageUrlLengthMin || utf8.RuneCountInString(url) > imageUrlLengthMax {
            return nil, apperrors.ReqBadParam.Wrap(apperrors.ErrMismatchData, fmt.Sprintf("imageUrl must be between %s and %s characters", imageUrlLengthMin, imageUrlLengthMax))
        }
        image := &TweetImage{
            TweetID:  tweetId,
            ImageUrl: url,
        }
        result = append(result, *image)
    }

    return result, nil
}

GOにおいてスライスは参照型ではないので、引数と返り値の型に「*」を付与する必要はありません。

そのまま渡せば参照を渡してくれます。

zenn.dev

【DB】DBで空文字とNULLを区別するか

区別する場合

→メリット
- (特に文字列の場合)「そもそも値を使っていない」と「使ったけど未入力」を区別できる。

→デメリット
- NULL を使うとアプリ側で毎回チェックが必要になる
例:content カラムを NULL 可能にしておくと
- 画面描画時に NULL → "" に変換する処理が必要
- フロントに返すときに毎回 omitempty / null の扱い の検討が必要
- 入力なしなら空文字で保存してそのまま UI で出せば楽。


数値型の場合

  • 0 と NULL は意味が違うケースが多い
    例:
    • 0 → ユーザーが「0」という値を明示的に入力した
    • NULL → まだ入力されていない/計測されていない
  • 集計時に意味が大きく変わる
    • AVG(col) は NULL を無視する
    • 0 が入っていると平均が下がる
      数値型は NULL を許容して区別したほうがよいケースが圧倒的に多い

文字列の場合

  • 「そもそも使っていない」
  • 「使ってるけど今回は未入力」
    この区別をしたいときは NULL を使うべき。

結論(整理)

NULL を使うべき時

  • 数値カラムで「未入力」と「0」を区別したい
  • 文字列カラムでも「未設定」と「空入力」を区別したい
  • ビジネス的に「まだ値が存在しない」状態が重要なとき

空文字・ゼロ値で統一すべき時

  • UI/アプリで扱いが楽になる場合
  • 「未入力」と「空」を区別しない場合
  • 文字列で空白許容が自然な場合(Twittertweet.content など)

【Go】sqlcを使ってみる

sqlcを使ってみる

sqlcとは??

Go の O/Rマッパーです。

簡単に言うと、実行したいSQLを書くだけで、残りの「GoでSQLを実行するためのコード」をコマンド1つで生成してくれます。

なのでsqlcを使わない場合、以下のような流れで実装しますが、

SQLを書く
↓
GoでSQLを実行するためのコードを書く
↓
構造体・パラメータ・エラー処理を書く

sqlcを使った場合、以下のような手順で短縮できます

SQLを書く
↓
sqlc が Go のコードを自動生成
↓
呼び出すだけ




では実際に使ってみます

#sqlc.yml

version: "2"
sql:
  - engine: "postgresql"
    queries: "query.sql"
    schema: "schema.sql"
    gen:
      go:
        package: "tutorialpackage"
        out: "tutorialout"
        sql_package: "pgx/v5"
# schema.sql

CREATE TABLE authors (
  id   BIGSERIAL PRIMARY KEY,
  name text      NOT NULL,
  bio  text
);
#query.sql

-- name: GetAuthor :one
SELECT * FROM authors
WHERE id = $1 LIMIT 1;

-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY name;

-- name: CreateAuthor :one
INSERT INTO authors (
  name, bio
) VALUES (
  $1, $2
)
RETURNING *;

-- name: UpdateAuthor :exec
UPDATE authors
  set name = $2,
  bio = $3
WHERE id = $1;

-- name: DeleteAuthor :exec
DELETE FROM authors
WHERE id = $1;

上記の3ファイルを作る。こちらは一旦公式の内容を記載している

上記3ファイルのあるディレクトリで「sqlc generate」をターミナル上で実行する

どうディレクトリに3ファイルから生成されたディレクトリが生成される

ディレクトリ名はsqlc.ymlのoutのラインに記載した名前が使われる(今回だとtutorialout)

そのディレクトリ内の各ファイルのパッケージ名が「package」に記載の名前になる(今回だとtutorialpackage)


以下sqlc generateで生成された3ファイル

  • db.go
// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.29.0

package tutorialpackage

import (
    "context"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
)

type DBTX interface {
    Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
    Query(context.Context, string, ...interface{}) (pgx.Rows, error)
    QueryRow(context.Context, string, ...interface{}) pgx.Row
}

func New(db DBTX) *Queries {
    return &Queries{db: db}
}

type Queries struct {
    db DBTX
}

func (q *Queries) WithTx(tx pgx.Tx) *Queries {
    return &Queries{
        db: tx,
    }
}
  • models.go
// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.29.0

package tutorialpackage

import (
    "github.com/jackc/pgx/v5/pgtype"
)

type Author struct {
    ID   int64
    Name string
    Bio  pgtype.Text
}
  • query.go
// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.29.0
// source: query.sql

package tutorialpackage

import (
    "context"

    "github.com/jackc/pgx/v5/pgtype"
)

const createAuthor = `-- name: CreateAuthor :one
INSERT INTO authors (
  name, bio
) VALUES (
  $1, $2
)
RETURNING id, name, bio
`

type CreateAuthorParams struct {
    Name string
    Bio  pgtype.Text
}

func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) (Author, error) {
    row := q.db.QueryRow(ctx, createAuthor, arg.Name, arg.Bio)
    var i Author
    err := row.Scan(&i.ID, &i.Name, &i.Bio)
    return i, err
}

const deleteAuthor = `-- name: DeleteAuthor :exec
DELETE FROM authors
WHERE id = $1
`

func (q *Queries) DeleteAuthor(ctx context.Context, id int64) error {
    _, err := q.db.Exec(ctx, deleteAuthor, id)
    return err
}

const getAuthor = `-- name: GetAuthor :one
SELECT id, name, bio FROM authors
WHERE id = $1 LIMIT 1
`

func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) {
    row := q.db.QueryRow(ctx, getAuthor, id)
    var i Author
    err := row.Scan(&i.ID, &i.Name, &i.Bio)
    return i, err
}

const listAuthors = `-- name: ListAuthors :many
SELECT id, name, bio FROM authors
ORDER BY name
`

func (q *Queries) ListAuthors(ctx context.Context) ([]Author, error) {
    rows, err := q.db.Query(ctx, listAuthors)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var items []Author
    for rows.Next() {
        var i Author
        if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}

const updateAuthor = `-- name: UpdateAuthor :exec
UPDATE authors
  set name = $2,
  bio = $3
WHERE id = $1
`

type UpdateAuthorParams struct {
    ID   int64
    Name string
    Bio  pgtype.Text
}

func (q *Queries) UpdateAuthor(ctx context.Context, arg UpdateAuthorParams) error {
    _, err := q.db.Exec(ctx, updateAuthor, arg.ID, arg.Name, arg.Bio)
    return err
}

そんなこんなでsqlcを使ったDB操作コードの生成が完了しました。

ここからは私が疑問に思ったことをつらつら書いていきます

pgtypeとは

sqlcで生成されたAuthorテーブルのentityである構造体Authorに

何やら見慣れない型あり。

pgtype.Text???

type Author struct {
    ID   int64
    Name string
    Bio  pgtype.Text ←こいつ!!!
}

これはなんぞやと調べました

type Text struct {
    String string
    Valid  bool
}

Datatypes — sqlc 1.30.0 documentation

どうやらNOT NULLのカラムに対してはstring型が適用され、

NuLLを許容するカラムにはpgtype.Text型が適用されるようですね。

よくその定義を見るとValidのフィールドがあります。

こちらがNULL を Valid: false で表現しているようで、

有効な値を String に格納し、Valid: true で示すと言う仕組み。

sqlc が pgtype.Text を使うのは、NULL を許可するカラムを型安全に扱うため。

NOT NULL 制約があれば、通常は string が生成されると言うことですね。

え、NULL と空文字は区別しないといけないの??

実務でよくある具体例を挙げます。

具体例1: ユーザープロフィールの「自己紹介」欄

SNSアプリで、ユーザーが自己紹介を入力できる欄があるとします。

// データベースの状態

// ユーザーA: bio = NULL ← まだ一度も入力していない

// ユーザーB: bio = "" ← 一度入力して、全部消した(意図的に空白にした)

// ユーザーC: bio = "こんにちは" ← 通常の値

区別が必要な理由

UIでの表示を変えたい場合:

func getBioDisplay(author *tutorialpackage.Author) string {    
    if !author.Bio.Valid {        
     // NULL = まだ入力していない → プレースホルダーを表示        
       return "自己紹介を入力してください(未入力)"    
    }    
    if author.Bio.String == "" {        
        // 空文字列 = 意図的に空白にした → 何も表示しない        
        return ""  // または "(空白)"    
    }    // 通常の値    
    return author.Bio.String
}

通知やリマインダー:

NULL → 「まだ入力していません。入力しませんか?」

空文字列 → 「空白のままです」(既に触ったので通知しない)

具体例2: 検索・フィルタリング

シナリオ

商品管理システムで、商品説明(description)を検索できるとします。

-- 商品テーブル-- 商品A: description = NULL ← 説明がまだ追加されていない

-- 商品B: description = "" ← 説明欄を意図的に空白にした

-- 商品C: description = "美味しい" ← 通常の説明

区別が必要な理由

// 「説明が未設定の商品」を探したい場合

func findProductsWithoutDescription(products []Product) []Product {    
    var result []Product    
    for _, p := range products {        
        if !p.Description.Valid {            
            // NULL = 説明が未設定 → リストに追加            
            result = append(result, p)        
        }        
        // 空文字列は「説明を空白にした」という意図があるので除外    
    }    
    return result
}

// 「説明が空白の商品」を探したい場合
func findProductsWithEmptyDescription(products []Product) []Product {    
     var result []Product    
     for _, p := range products {        
         if p.Description.Valid && p.Description.String == "" {            
         // 空文字列 = 意図的に空白にした → リストに追加            
         result = append(result, p)        
         }    
      }    
      return result
}

区別が必要な主な場面:

  • UI表示の違い

    • NULL → 「未入力です」などのプレースホルダ

    • 空文字列 → 何も表示しない、または「空白」と表示

  • バリデーション

    • NULL → 任意項目ならOK

    • 空文字列 → エラーにする場合がある

  • ビジネスロジック

    • NULL → デフォルト値を使う

    • 空文字列 → 明示的に空白を選択したと判断

多くの場合、NULLは「まだ処理していない」、空文字列は「処理した結果が空白」という意味の違いがあります。この区別が重要になる場面は多いです。

【GO】GOでマイグレーションツールによるDB管理

最初にGO言語用のマイグレーションツールをインストール

$ brew install golang-migrate

インストール確認

migrate -version
v4.18.3

マイグレーションファイルを作成

  • プロジェクトのルート直下で以下コマンドを実行
$ migrate create -ext sql -dir db/migrations -seq create_users_table
  • 以下2ファイルがdb/migrations配下に生成されていればOKです。(中身は空です)

    • 000001_create_users_table.up.sql
    • 000001_create_users_table.down.sql
  • -ext

  • -dir
  • -seq
    • sequence の省略形。マイグレーションファイルの名前 「逐次番号付き」にするフラグ。タイムスタンプではなく、1, 2, 3…の連番でファイル名がつく。
    • -sqlをつけなければ以下のタイムスタンプ付きのファイルが生成されます
      • 20250811120645_create_users_table.down.sql
      • 20250811120645_create_users_table.up.sql

マイグレートするSQLを記載する

生成した2ファイルにマイグレート用とロールバック用のDDLを追記しましょう

まず000001_create_users_table.up.sqlから。

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL,
    is_active boolean default FALSE
);


次に000001_create_users_table.down.sql

DROP TABLE IF EXISTS users;

最後に、マイグレーションを実行してテーブルを作成しましょう。

実行したdocker-compose.ymlは以下の通り。dbセクションのみ見てくれれば大丈夫です。

services:
  go:
    build:
      dockerfile: ./Dockerfile
      context: .
    container_name: twitter-clone-api
    ports:
      - "8080:8080"
    volumes:
      - ./:/go/src
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true

  db:
    image: postgres
    container_name: twitter-clone-db
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - postgres:/var/lib/postgresql/data
      # - ./sql/init:/docker-entrypoint-initdb.d
    ports:
      - 5432:5432
    expose:
      - 5432
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5
  mailcatcher:
    image: schickling/mailcatcher
    container_name: mailcatcher
    ports:
      - "1080:1080"
    networks:
      - default
      - twitter-clone-go_default #snowboardアプリのネットワーク名
volumes:
  postgres:

networks:
  twitter-clone-go_default:
    external: true

docker-compose up 後のテーブルの状態は

k_tanaka@Mac twitter-clone-go % docker compose exec db bash
root@4d8e13a97bfd:/# psql -U postgres -d testdb
psql (17.5 (Debian 17.5-1.pgdg120+1))
Type "help" for help.

testdb=# \dt
Did not find any relations.
testdb=# 

テーブルはまだ作られていないようですね!

ではマイグレーションしてみましょう

以下コマンドでマイグレートできます。(userやパスワード、データベース名部分は変更してください)

k_tanaka@Mac twitter-clone-go % migrate --path db/migrations --database 'postgresql://postgres:mypassword@localhost:5432/testdb?sslmode=disable' -verbose up
testdb=# \dt
               List of relations
 Schema |        Name        | Type  |  Owner   
--------+--------------------+-------+----------
 public | email_verify_token | table | postgres
 public | schema_migrations  | table | postgres
 public | users              | table | postgres
(3 rows)

テーブルが作成されてますね!(すみません、記事に記載してませんがemail_verify_tokenについてもマイグレートファイルも作っています。)

  • --path マイグレーションファイルの場所
  • --database データベースの接続先
  • -verbose マイグレーション実行時の詳細をログに表示する
    • verbose の語源は ラテン語の “verbosus” で、意味は 「言葉が多い、饒舌な」 です。
    • 転じて出力をおしゃべりにする(詳細情報をたくさん出す)」という意味で使われます。
    • 通常よりも詳しい処理内容やログを表示する」モードをオンにする、みたいな命名由来です。
  • up 拡張子が up のファイルを実行する

お疲れ様でした。これでマイグレーションツールによるDBの管理ができるようになりました。ここまでお読みくださりありがとうございました。

※今後追記予定(docker-entrypoint-initdb.dからこっちに移行した理由。実際のマイグレーションツールの運用の手順や内容)