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
กฎ 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
- Package ตาม responsibility ไม่ใช่ตาม layer type —
package user(รวม handler, service, repo ของ user ไว้ด้วยกัน) ดีกว่าpackage handlersที่ยัดทุก domain ไว้ที่เดียว - หลีกเลี่ยง package ชื่อ
common,util,helpers— มันจะกลายเป็นที่ทิ้งทุกอย่างและ เกิด circular import ง่าย ให้ตั้งชื่อตามสิ่งที่มันทำจริงๆ เช่นstringutil,timeutil - Avoid import cycles — ถ้า package A ต้อง import B และ B ต้อง import A แปลว่า ออกแบบ boundary ผิด ให้ดึง shared type ออกมาเป็น package ที่สามที่ทั้งสองฝั่ง import ได้
- Package name สั้น, lowercase, ไม่มี underscore —
httpclientไม่ใช่http_client
"แพ็กตามเรื่อง ไม่ใช่แพ็กตามประเภทงาน" — ลองนึกภาพจัดกล่องของย้ายบ้าน: จัดตาม "ห้องที่จะใช้"
(domain เช่น user, order) ง่ายต่อการหยิบใช้มากกว่าจัดตาม "ประเภทวัตถุ" (เช่น กล่อง "ของกลม
ทั้งหมด" กล่อง "ของเหลี่ยมทั้งหมด" ที่ปนกันข้ามห้อง) — package handlers ที่ยัดทุก domain
ไว้ด้วยกันก็เหมือนกล่อง "ของกลมทั้งหมด" นั่นแหละ
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 ง่ายกว่า
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 เดียวกันได้พร้อมกัน โดยไม่ชน
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 ใหม่แบบไม่ตั้งใจ
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.