Skip to main content

Go Project Structure

Go ไม่มีโครงสร้างบังคับจากภาษา แต่ community สรุปแนวทางที่ใช้กันแพร่หลายไว้ที่ golang-standards/project-layout — ด้านล่างคือ subset ที่ใช้บ่อยที่สุดในโปรเจกต์จริง (ไม่ต้องใช้ทุกโฟลเดอร์ ถ้าโปรเจกต์เล็ก)

myservice/
├── cmd/
│ └── myservice/
│ └── main.go // entrypoint เท่านั้น — ควรบางที่สุด
├── internal/ // โค้ดที่ "ห้าม" module อื่น import (compiler บังคับ!)
│ ├── handler/ // HTTP/gRPC handlers
│ ├── service/ // business logic
│ ├── repository/ // data access layer
│ └── config/ // โหลด config/env
├── pkg/ // โค้ดที่ตั้งใจให้ module อื่น import ได้
├── api/ // proto, OpenAPI spec
├── migrations/ // SQL migration files
├── scripts/ // build/deploy scripts
├── go.mod
└── go.sum

ทำไม internal/ สำคัญ​

internal/ ไม่ใช่แค่ธรรมเนียม — Go compiler บังคับ ว่า package ภายใน internal/ จะ import ได้แค่จาก code ที่อยู่ใน parent directory ของ internal เท่านั้น ใช้แยก "public API" (สิ่งที่ pkg/ export) ออกจาก "implementation detail" (internal/) อย่างชัดเจน

🧠 จำง่ายๆ

"internal = ห้องหลังบ้าน" — ใครก็เข้ามา "ทาง pkg/" (ประตูหน้า) ได้ แต่ internal/ คือห้อง หลังบ้านที่เข้าได้เฉพาะคนในบ้านเดียวกัน (code ใต้ parent directory เดียวกัน) เท่านั้น ไม่ใช่แค่ convention — ประตูล็อกจริงโดย compiler

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

กฎ internal/ ไม่ได้ผูกกับ module เดียว — มันผูกกับ parent directory ของ internal เอง ซึ่งหมายความว่าถ้ามี 2 module คนละ repo แต่แชร์ parent path เดียวกัน (เช่น monorepo ที่มีหลาย go.mod) module ที่อยู่ใต้ parent เดียวกันสามารถ import internal/ ของกันและกันได้ แม้จะเป็น คนละ module name ก็ตาม — หลายคนเข้าใจผิดว่า internal/ ผูกกับ "module" แต่จริงๆ ผูกกับ file path ล้วนๆ compiler เช็คแค่ว่า path ของผู้ import นั้นมี internal's parent เป็น prefix หรือไม่เท่านั้น

Package Design Principles​

  1. Package ตาม responsibility ไม่ใช่ตาม layer type — package user (รวม handler, service, repo ของ user ไว้ด้วยกัน) ดีกว่า package handlers ที่ยัดทุก domain ไว้ที่เดียว
  2. หลีกเลี่ยง package ชื่อ common, util, helpers — มันจะกลายเป็นที่ทิ้งทุกอย่างและ เกิด circular import ง่าย ให้ตั้งชื่อตามสิ่งที่มันทำจริงๆ เช่น stringutil, timeutil
  3. Avoid import cycles — ถ้า package A ต้อง import B และ B ต้อง import A แปลว่า ออกแบบ boundary ผิด ให้ดึง shared type ออกมาเป็น package ที่สามที่ทั้งสองฝั่ง import ได้
  4. Package name สั้น, lowercase, ไม่มี underscore — httpclient ไม่ใช่ http_client
🧠 จำง่ายๆ

"แพ็กตามเรื่อง ไม่ใช่แพ็กตามประเภทงาน" — ลองนึกภาพจัดกล่องของย้ายบ้าน: จัดตาม "ห้องที่จะใช้" (domain เช่น user, order) ง่ายต่อการหยิบใช้มากกว่าจัดตาม "ประเภทวัตถุ" (เช่น กล่อง "ของกลม ทั้งหมด" กล่อง "ของเหลี่ยมทั้งหมด" ที่ปนกันข้ามห้อง) — package handlers ที่ยัดทุก domain ไว้ด้วยกันก็เหมือนกล่อง "ของกลมทั้งหมด" นั่นแหละ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Go ไม่มี circular import ระดับ package ได้เลยแม้แต่น้อย (ต่างจากหลายภาษาที่ lazy-load แก้ปัญหานี้ได้บางส่วน) compiler จะ error ทันทีตอน build ไม่ใช่ runtime — ฟังดูเหมือนข้อจำกัด แต่จริงๆ คือ feature ที่บังคับให้ dependency graph เป็น DAG เสมอ (directed acyclic graph) ผลคือ go build คำนวณลำดับ compile ของทุก package ได้แบบ deterministic และ parallelize ได้ เต็มที่ ซึ่งเป็นส่วนหนึ่งที่ทำให้ Go build เร็วกว่าภาษาที่ยอมให้มี circular dependency มาก

Dependency Injection แบบ Go (ไม่ต้องใช้ framework หนัก)​

Go ไม่มี DI container มาตรฐานแบบ Spring/NestJS — นิยมใช้ constructor injection แบบตรงไปตรงมา

type UserService struct {
repo UserRepository // เป็น interface ไม่ใช่ concrete type
logger *slog.Logger
}

func NewUserService(repo UserRepository, logger *slog.Logger) *UserService {
return &UserService{repo: repo, logger: logger}
}

ข้อดี: test ได้ง่าย (mock UserRepository แทนของจริง), ไม่มี "magic" reflection-based wiring ให้ debug ยาก ถ้าโปรเจกต์ใหญ่มากจนการ wire ด้วยมือเริ่มรก ค่อยพิจารณา google/wire (code-gen based, ไม่มี runtime reflection)

🧠 จำง่ายๆ

"DI แบบ Go คือยื่นของให้มือ ไม่ใช่ขอของจากตู้เวทมนตร์" — ภาษาที่มี DI container (Spring, NestJS) เหมือนมีตู้เวทมนตร์ที่ขอของอะไรก็ได้มาโดยไม่ต้องรู้ที่มา (reflection-based) ส่วน Go นิยม "ยื่นของให้มือโดยตรง" ผ่าน constructor parameter — เห็นชัดเจนว่าของมาจากไหน debug ง่ายกว่า

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Interface ที่ใช้สำหรับ DI ควรประกาศไว้ฝั่ง consumer (ผู้ใช้) ไม่ใช่ฝั่ง implementation (ย้อนกลับจากสัญชาตญาณของคนมาจากภาษาอื่น) เช่น UserService ควรเป็นคนประกาศ interface UserRepository ที่ตัวเองต้องการ ไม่ใช่ให้ package repository เป็นคนประกาศ interface ของ ตัวเอง หลักนี้เรียกว่า consumer-defined interface — ทำให้ service package ไม่ต้อง import repository package เลยด้วยซ้ำ (แค่ import ตัว interface ของตัวเอง) ลด coupling ได้มากกว่าที่ คิด และเป็นแนวทางที่ Go standard library ใช้เองทั่วทั้งระบบ (เช่น io.Reader ถูกประกาศใน io ไม่ใช่ใน os)

go.mod & Versioning​

go mod init github.com/org/myservice
go mod tidy # ลบ dependency ที่ไม่ใช้ + เพิ่มที่ขาด
go mod why <pkg> # ดูว่าทำไม dependency นี้ถึงถูกดึงเข้ามา

Semantic versioning: v0.x.x ถือว่า unstable API เปลี่ยนได้ตลอด, v2+ ต้องเปลี่ยน import path เป็น /v2 (Go module major version suffix) — เป็นกฎที่ interviewer มือใหม่มักไม่รู้

🧠 จำง่ายๆ

"v2 ต้องเปลี่ยนที่อยู่บ้าน" — เหมือนบ้านเลขที่เดิมที่ปรับปรุงใหญ่จนเข้ากันไม่ได้กับของเก่า (breaking change) Go บังคับให้ "ย้ายที่อยู่" (เปลี่ยน import path เป็น /v2) เพื่อให้คนที่ยัง ใช้ v1 อยู่กับคนที่ย้ายไป v2 แล้ว อยู่ร่วมกันใน dependency graph เดียวกันได้พร้อมกัน โดยไม่ชน

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Go module ใช้ Minimal Version Selection (MVS) ไม่ใช่ "ดึง version ล่าสุดเสมอ" แบบ npm — ถ้า dependency หลายตัวต้องการ package เดียวกันคนละ version, Go จะเลือก version ต่ำที่สุดที่ สูงพอจะตอบสนองทุกความต้องการ (ไม่ใช่สูงสุดเท่าที่มี) หลักการนี้ทำให้ build reproducible สูงมาก (build วันนี้กับปีหน้าได้ dependency tree เหมือนเดิม ถ้าไม่มีใครสั่ง go get -u เอง) ต่างจาก ecosystem อื่นที่ build วันนี้กับพรุ่งนี้อาจได้ patch version ใหม่แบบไม่ตั้งใจ

English Summary

Go has no enforced project layout, but the community-standard pattern separates cmd/ (thin entrypoints), internal/ (compiler-enforced private packages — a huge Go-specific interview differentiator), and pkg/ (intentionally public code). Design packages around business responsibility, not technical layers; avoid grab-bag util/common packages, which breed import cycles. Dependency injection is typically plain constructor injection with interfaces — no runtime DI container needed, and idiomatic Go defines the interface on the consumer side, not the implementation side, to minimize coupling. Module majors v2+ require an /v2 import-path suffix per Go's semantic-versioning rules. Deeper cuts: the internal/ rule is enforced by file path prefix, not module identity, so sibling modules under the same parent can share it; Go forbids import cycles entirely, which keeps the dependency graph a DAG and enables fully parallel, deterministic builds; and Go modules use Minimal Version Selection — picking the lowest version that satisfies all requirements, not the latest — for highly reproducible builds.