บทที่ 23 · Part 6 — Evidence-Driven Quality
Organization-Wide Go Standards
แยก standard, contract, tooling, template และ private module เพื่อหยุด contract drift ข้ามหลาย codebase
องค์กรหนึ่งเริ่มนำ Go มาใช้กับระบบใหม่ ในช่วงแรกมีเพียง 3 services และ developer ไม่กี่ทีม
แต่ละทีมจึงตัดสินใจเรื่องพื้นฐานกันเอง ทีมหนึ่งใช้ log/slog อีกทีมมี logger wrapper ที่ย้ายมาจาก
ระบบเก่า ส่วนอีกทีมส่ง log เป็น JSON ด้วย function ที่เขียนขึ้นภายใน repository
HTTP response ก็เกิดขึ้นในลักษณะเดียวกัน Service แรกคืน success, message และ data
ส่วน service ถัดมาเพิ่ม error เพราะต้องการส่ง error code ให้ mobile application ขณะที่อีก service
ใช้ response_code และ response_message ตามรูปแบบของระบบที่มันเชื่อมต่ออยู่
ในตอนนั้นแต่ละการตัดสินใจอาจสมเหตุผล ทีมมีงานที่ต้องส่งมอบและยังไม่มีหลักฐานว่ารูปแบบใดเหมาะจะใช้ เป็นมาตรฐานขององค์กร การเขียน code เล็กน้อยซ้ำกันอาจมีต้นทุนต่ำกว่าการออกแบบ shared package ที่ยังไม่รู้ ว่าใครจะใช้
ปัญหาเริ่มชัดเมื่อองค์กรมี Go services เพิ่มขึ้นจาก 3 เป็นหลายสิบระบบ Developer ที่เริ่ม service ใหม่ ไม่ได้เริ่มจาก business problem เพียงอย่างเดียว แต่ต้องตัดสินใจซ้ำว่า logger ควรมีหน้าตาอย่างไร, error ต้องมี field อะไร, response envelope ใช้แบบไหน, OpenAPI validation error ควรถูกแปลงอย่างไร และจะเชื่อม trace ID เข้ากับ log ด้วยวิธีใด
เมื่อแต่ละทีมตอบคำถามเหล่านี้แยกกัน สิ่งที่เคยเป็นความแตกต่างเล็กน้อยจึงค่อย ๆ กลายเป็นความเสี่ยง ระดับองค์กร
จบบทนี้คุณจะ
- แยก organizational standard, shared module, template และ local code ออกจากกัน
- ตัดสินได้ว่า code แบบใดควรย้ายไป private Go module
- ออกแบบ logging, error และ HTTP response standard โดยไม่สร้าง package กลางขนาดใหญ่
- publish และ version private module ด้วย Go module workflow
- rollout มาตรฐานข้ามหลาย repositories โดยไม่ทำ big-bang migration
เมื่อ Local Convention กลายเป็น Organizational Risk
ลองนึกถึงเหตุการณ์ที่ฝ่ายปฏิบัติการต้องค้นหา request หนึ่งซึ่งเดินทางผ่าน 5 services
Service แรกบันทึก identifier ด้วย field trace_id Service ที่ 2 ใช้ traceId Service ที่ 3
เก็บไว้ใน correlation_id ส่วนอีก 2 services ไม่ได้บันทึก identifier นี้เลย แม้ทุกระบบจะมี log
และทุกทีมอาจมองว่า logging ของตนเองทำงานถูกต้อง แต่ operator ไม่สามารถค้นหา request เดียว
ข้ามทุกระบบด้วย query เดียวได้
ปัญหาเดียวกันเกิดกับ HTTP error หาก client ต้องเรียกหลาย services มันอาจต้องรองรับ response หลายรูปแบบ:
{
"success": false,
"message": "order not found",
"data": null,
"error": {
"code": "ORDER_NOT_FOUND",
"details": ""
}
}
อีก service อาจตอบว่า:
{
"response_code": 40401,
"response_message": "not found",
"error_data": "order does not exist"
}
ความแตกต่างนี้ไม่ได้สร้างภาระเฉพาะตอนเขียน client ครั้งแรก ทุกครั้งที่เพิ่ม service, เปลี่ยน error หรือสร้าง SDK ใหม่ ทีมต้องจำว่าระบบใดใช้ contract แบบใด และ field ใดเป็นข้อมูลสำหรับคนอ่านหรือเป็นข้อมูล ที่โปรแกรมสามารถใช้ตัดสินใจได้
ผลกระทบจะชัดขึ้นอีกเมื่อเกิดปัญหาด้านความปลอดภัย สมมติองค์กรพบว่า logger หลายตัวสามารถบันทึก access token หรือข้อมูลส่วนบุคคลได้ หากแต่ละ repository มี logging implementation ของตนเอง การแก้ปัญหาจะต้องเปิด pull request หลายชุด ติดตามว่าระบบใดแก้แล้ว และตรวจอีกครั้งว่าแต่ละ implementation ปิดช่องโหว่ด้วยวิธี เดียวกันจริงหรือไม่
สัญญาณว่า duplication เริ่มเป็น organizational risk มีดังนี้:
- client ต้องเขียน error parser หลายแบบให้ services ในองค์กรเดียวกัน
- dashboard และ alert ต้องรู้ชื่อ log field หรือ metric label หลายชุด
- bug หรือช่องโหว่เดียวต้องเปิด pull request ในหลาย repositories
- developer ที่เริ่ม service ใหม่ต้องเลือก logger, error envelope และ middleware ใหม่ทุกครั้ง
- package ที่ตั้งใจทำหน้าที่เดียวกันมี behavior ต่างกันโดยไม่มี architectural reason
- version หรือ configuration ของ infrastructure libraries แตกต่างจนการ upgrade ต้องทำเป็นโครงการใหญ่
เป้าหมายของ standardization ไม่ใช่ทำให้ source code ของทุก service เหมือนกันทั้งหมด แต่คือระบุให้ได้ว่า ส่วนใดจำเป็นต้องเหมือนกัน และทำให้ส่วนนั้นมี owner, contract และวิธีตรวจสอบที่ชัดเจน
ในบทนี้จะใช้คำว่า organizational shared module สำหรับ Go module ที่องค์กรดูแลร่วมกัน เพื่อไม่ให้
สับสนกับ Go standard library เช่น net/http, errors และ log/slog
Standard ไม่ได้แปลว่าต้องเป็น Library
เมื่อทีมเห็น code ซ้ำกัน คำตอบแรกมักเป็น “ย้ายไป package กลาง” แต่ code ที่ดูคล้ายกันอาจต้องการวิธีจัดการ คนละแบบ
ตัวอย่างเช่น กฎว่า error string ใน Go ควรเริ่มด้วย lowercase เป็นข้อตกลงในการเขียน code ทีมสามารถบันทึก ไว้ใน style guide และใช้ linter ช่วยตรวจได้ การสร้าง function กลางเพียงเพื่อเปลี่ยนข้อความเป็น lowercase ไม่ได้ช่วยให้ interface ลึกขึ้น และเพิ่ม dependency โดยไม่ซ่อน complexity ที่มีความหมาย
ในทางกลับกัน รูปแบบ HTTP error เป็น contract ที่ client พึ่งพา หากองค์กรต้องการให้ services ใช้ response แบบเดียวกัน การมีเอกสารเพียงย่อหน้าเดียวอาจไม่พอ ควรมี OpenAPI schema, ตัวอย่าง response และ contract test ที่ตรวจว่า implementation จริงยังทำตามข้อกำหนด
ส่วน repository template แก้ปัญหาอีกชนิดหนึ่ง มันช่วยให้ service ใหม่เริ่มต้นด้วย folder structure, CI และ composition root ที่เหมาะสม แต่ทันทีที่ developer copy template ไปสร้าง repository ใหม่ code ชุดนั้น ก็แยกออกจากต้นฉบับ หาก template มีช่องโหว่ การแก้ต้นฉบับจะไม่แก้ services ที่สร้างไปแล้ว
องค์กรจึงมีเครื่องมือหลายแบบ และต้องเลือกให้ตรงกับสิ่งที่ต้องการควบคุม:
| กลไก | เหมาะกับ | ตัวอย่าง |
|---|---|---|
| Convention | เรื่องที่ต้องใช้ judgment | naming, package layout, error message style |
| Contract | สิ่งที่ consumer ต้องพึ่งพา | OpenAPI schema, error code, log field vocabulary |
| Tooling | กฎที่ตรวจแบบ deterministic ได้ | formatter, linter, architecture test, CI |
| Shared module | behavior ที่ต้องแก้ครั้งเดียวแล้วทุก consumer upgrade ได้ | redaction handler, error mapping, telemetry setup |
| Template | จุดเริ่มต้นที่แต่ละ service ต้องปรับเอง | repository layout, composition root, deployment files |
การแยกนี้ช่วยให้ทีมไม่พยายามแก้ทุกปัญหาด้วย library เดียว Style rule ควรอยู่ใน style guide และ tooling ส่วน wire contract ควรอยู่ใน schema และ tests ขณะที่ behavior ซึ่งมี implementation ซับซ้อนและควรถูกแก้ จากจุดเดียวจึงค่อยเป็น shared module
เกณฑ์ก่อนแยกเป็น Package กลาง
การพบ function คล้ายกันใน 2 repositories เป็นเพียงสัญญาณให้เริ่มตรวจสอบ ยังไม่ใช่เหตุผลเพียงพอให้สร้าง private module
สมมติ 2 services มี function สำหรับสร้าง UUIDv7 เหมือนกัน:
func NewID() string {
return uuid.Must(uuid.NewV7()).String()
}
การนำ function นี้ไปไว้ใน module กลางอาจลด code ได้ไม่กี่บรรทัด แต่เพิ่ม repository, version, release process และ dependency ที่ทุก consumer ต้องติดตาม หากองค์กรไม่มี policy เพิ่มเติม เช่น prefix, validation, parsing หรือ compatibility requirement การเรียก UUID library โดยตรงอาจง่ายกว่า
เปรียบเทียบกับ logging setup ที่ต้องทำหลายเรื่องพร้อมกัน:
- สร้าง JSON handler
- เพิ่มชื่อ service และ environment
- อ่าน trace ID จาก context
- ลบหรือปิดบังข้อมูลสำคัญ
- กำหนดชื่อ field ให้เหมือนกัน
- ทดสอบว่า log ที่ได้เป็นไปตาม schema
กรณีหลังมี behavior มากพอให้ซ่อนไว้หลัง interface ขนาดเล็ก เมื่อแก้ redaction policy หรือเพิ่ม field มาตรฐาน ทีมสามารถออก module version ใหม่ให้ services เลือก upgrade ได้ นี่คือ leverage ที่ shared module ควรสร้าง
ก่อน extract ให้ถามคำถามต่อไปนี้:
- มี consumer จริงอย่างน้อย 2 ตัวหรือยัง
- Behavior ส่วนใดต้องเหมือนกัน และส่วนใดควรต่างตาม workload
- Interface นิ่งพอให้ทีมอื่นพึ่งพาหรือยัง
- ถ้าลบ module นี้ complexity จะหายไป หรือกระจายกลับไปทุก consumer
- Module มี owner ที่ review, release และตอบคำถามได้หรือไม่
- มี test ที่พิสูจน์ behavior ผ่าน interface ที่ consumer ใช้หรือไม่
- การเพิ่ม dependency คุ้มกับ code, failure mode และ upgrade work ที่มันแทนหรือไม่
ข้อ 4 เรียกว่า deletion test ลองจินตนาการว่าลบ shared module ออก หากสิ่งที่กลับไปยังทุก service เป็นเพียง function 1 บรรทัด module นั้นอาจยังไม่สร้างประโยชน์มากพอ แต่หากแต่ละ service ต้องสร้าง redaction, trace correlation และ error translation ขึ้นใหม่ แสดงว่า module กำลังซ่อน complexity ที่มีคุณค่า
| Candidate | แนวโน้ม |
|---|---|
| Structured logging handler + redaction | เหมาะกับ shared module |
| OpenAPI validation error translation | เหมาะเมื่อใช้ validator และ contract เดียวกัน |
| HTTP error envelope mapping | เหมาะเมื่อ wire contract ถูกกำหนดร่วมกันแล้ว |
| OpenTelemetry provider setup | เหมาะเมื่อ exporter และ resource policy สม่ำเสมอ |
| Database pool size | ไม่ควรแชร์เป็นค่ากลาง เพราะขึ้นกับ runtime และ workload |
| Queue name หรือ Temporal task queue | เป็น service-local configuration |
| Domain error codes | แต่ละ domain ควรเป็นเจ้าของ |
| Function wrapper บาง ๆ เหนือ standard library | มักใช้ convention หรือ template ก็เพียงพอ |
Shared module คือ public commitment ภายในองค์กร
คำว่า private ไม่ได้แปลว่าเปลี่ยน interface ได้โดยไม่สน consumer ทุก exported name, error mode, default, side effect และ performance characteristic อาจกลายเป็นสิ่งที่ repository อื่นพึ่งพา
ออกแบบ Module ตาม Capability
เมื่อเริ่มสร้าง code กลาง สิ่งที่ควรระวังคือ repository ชื่อ common, utils, shared หรือ platform
ชื่อเหล่านี้ไม่ได้บอกว่า module รับผิดชอบอะไร จึงไม่มีเกณฑ์ชัดว่า code แบบใดเพิ่มเข้ามาได้บ้าง ช่วงแรกอาจมี เพียง logger กับ response helper แต่เมื่อเวลาผ่านไป database client, retry, cache, configuration และ domain types อาจถูกเพิ่มเข้ามาเรื่อย ๆ จน service ที่ต้องการเพียง response helper ต้องรับ dependencies ซึ่งไม่เกี่ยวข้องมาด้วย
ควรตั้งชื่อ package และ module ตาม capability ที่ consumer ต้องการใช้:
go-api-contract/
go.mod
problem/
httpresponse/
internal/
go-openapi/
go.mod
oapivalidation/
go-observability/
go.mod
logging/
otelsetup/
โครงสร้างนี้เป็นเพียงตัวอย่าง ไม่ได้หมายความว่าทุกองค์กรต้องเริ่มด้วย 3 repositories หาก packages มี owner, dependency graph และ release cadence เดียวกัน อาจเริ่มใน module เดียวก่อน แล้วค่อยแยกเมื่อพบว่าการเปลี่ยน logging ไม่ควรบังคับให้ release HTTP response package ไปพร้อมกัน
คำถามสำคัญคือ consumer ได้อะไรจาก interface ที่ต้องเรียนรู้ หาก logging.New มี parameter ไม่กี่ตัว
แต่ซ่อน JSON encoding, redaction, trace correlation และ field normalization ไว้ภายใน นั่นคือ deep module
ซึ่งสร้าง leverage ให้ทุก service และทำให้ knowledge อยู่ในจุดเดียว
ในทางกลับกัน package ที่ export type และ option จำนวนมากเพียงเพื่อส่งต่อไปยัง third-party logger อาจทำให้ caller ต้องเรียนรู้ทั้ง interface ขององค์กรและ interface ของ dependency โดยไม่ได้ลด complexity จริง
Error Standard ต้องเริ่มจาก Caller Decision
เมื่อพูดถึง error standard องค์กรมักเริ่มจากการออกแบบ struct กลางที่มี field จำนวนมาก เช่น code, message, details, HTTP status, retryable, severity และ stack trace
ปัญหาคือ field เหล่านี้ไม่ได้อยู่ในความรับผิดชอบระดับเดียวกันทั้งหมด
Application error ควรบอกสิ่งที่ caller จำเป็นต้องใช้ตัดสินใจ เช่น input ไม่ถูกต้อง, resource ไม่มีอยู่ หรือ operation ขัดกับ state ปัจจุบัน ส่วน HTTP status เป็นการแปล application outcome ไปยัง transport และ retry policy ต้องพิจารณาร่วมกับ operation semantics
ตัวอย่างเช่น timeout จาก dependency อาจ retry ได้สำหรับการอ่านข้อมูล แต่การ retry คำสั่งตัดเงินซึ่งไม่มี
idempotency key อาจสร้างผลลัพธ์ซ้ำ การใส่ Retryable: true ไว้ใน error กลางโดยไม่รู้ว่า caller
กำลังทำ operation ใดจึงอาจเป็น contract ที่กว้างเกินไป
ส่วนที่กำหนดร่วมกันได้มีดังนี้:
- error code เป็น stable machine-readable string
- public message ปลอดภัยสำหรับ client
- details ไม่รั่ว internal error, credential หรือข้อมูลส่วนบุคคล
- unknown error ถูก mask ที่ external seam
- cause chain เก็บไว้สำหรับ diagnostics
- HTTP adapter log final failure เพียงครั้งเดียว
- error response ใช้ schema เดียวกัน
ส่วนที่ยังเป็นของแต่ละ domain ได้แก่:
ORDER_NOT_FOUND,ACCOUNT_SUSPENDEDหรือ code เฉพาะ use case- message ที่เหมาะกับ consumer ของ operation นั้น
- เงื่อนไขว่า failure เป็น business outcome หรือ system error
- retry policy ที่ขึ้นกับ idempotency และ operation semantics
const CodeOrderReferenceInvalid problem.Code = "ORDER_REFERENCE_INVALID"
err := problem.New(
problem.KindInvalid,
CodeOrderReferenceInvalid,
"order reference is invalid",
problem.WithDetails(`field "reference": maximum string length is 64`),
)
return err
Constant นี้อยู่กับ domain ที่เป็นเจ้าของความหมาย ส่วน shared module มีเพียง type และกลไกประกอบ error Application code จึงคืน error ด้วย vocabulary ของตนเอง จากนั้น HTTP adapter ที่ seam จึงแปลงเป็น status และ response:
func HandleError(ctx context.Context, w http.ResponseWriter, err error) {
p := problem.From(err)
status := httpresponse.Status(p.Kind())
httpresponse.Write(ctx, w, status, p)
}
วิธีนี้ทำให้ application module ไม่ต้องรู้ว่ามันจะถูกเรียกจาก HTTP, worker หรือ command line และเปิดทางให้ adapter แต่ละชนิดแปล error ตาม contract ของตนเอง
Error Code ควรเป็น Number หรือ Enum
number กับ enum ไม่ได้เป็นตัวเลือกคนละประเภท enum หมายถึงชุดค่าที่อนุญาต และสมาชิกในชุดนั้น
อาจเป็น number หรือ string ก็ได้ สำหรับ public error contract บทนี้แนะนำ string ที่มีค่าจำกัดแบบ enum
แทนเลขลำดับ
สมมติ client ได้ error code ต่อไปนี้:
{
"code": 10417
}
คนอ่านยังไม่รู้ว่า 10417 หมายถึงอะไร ต้องเปิดเอกสารหรือตารางกลางเพื่อแปลทุกครั้ง แต่ค่าต่อไปนี้
อ่านได้จาก log, test failure, API response และ incident report ทันที:
{
"code": "ORDER_NOT_FOUND"
}
Numeric code ยังสร้างภาระเมื่อองค์กรโตขึ้น เพราะต้องมีผู้จัดสรรช่วงตัวเลข ป้องกันเลขชนกัน และอธิบายว่า
แต่ละหลักมีความหมายอย่างไร ที่สำคัญ HTTP status เป็นตัวเลขซึ่งบอก transport category อยู่แล้ว เช่น 404
หรือ 409 Application code จึงควรเพิ่มสาเหตุที่ HTTP status ไม่ได้บอก ไม่ใช่กล่าวซ้ำว่า NOT_FOUND
ใน Go ใช้ named string type กับ constants ซึ่งทำหน้าที่ใกล้เคียง enum:
type Code string
const (
CodeOrderNotFound Code = "ORDER_NOT_FOUND"
CodeOrderAlreadyExists Code = "ORDER_ALREADY_EXISTS"
CodeInternal Code = "INTERNAL"
)
เพื่อไม่ให้ responsibility หลายระดับปนกัน ให้แยก Kind, Code และ HTTP status:
| ค่า | หน้าที่ | ตัวอย่าง |
|---|---|---|
Kind | กลุ่มความผิดพลาดที่ application ใช้ภายใน | NotFound, Invalid, Conflict |
Code | contract ที่ client ใช้ตัดสินใจ | ORDER_NOT_FOUND |
| HTTP status | ความหมายระดับ HTTP | 404 |
Kind เป็นข้อมูลภายในและเป็นชุดค่าปิด จึงใช้ iota ได้ตราบใดที่ไม่บันทึกลง database และไม่ส่งข้าม
network ส่วน Code เป็น public contract จึงควรเป็น string ที่คงที่ ถึงแม้จะจัดลำดับ constants ใหม่
ค่าใน JSON ก็ไม่เปลี่ยน
type Kind uint8
const (
KindUnknown Kind = iota
KindInvalid
KindNotFound
KindConflict
KindUnavailable
)
อย่าส่งค่า `iota` ข้าม API
หากมีคนแทรก constant ใหม่ตรงกลาง ลำดับตัวเลขของค่าถัดไปจะเปลี่ยนทันที ทั้งที่ชื่อใน source code
ยังดูเหมือนเดิม iota จึงเหมาะกับค่าภายใน process ไม่เหมาะกับ wire contract หรือข้อมูลที่บันทึกถาวร
OpenAPI รองรับ enum แต่ไม่ควรสร้าง enum กลางซึ่งรวม error code ทุก domain ในองค์กร เพราะรายการจะโต ต่อเนื่องและทุกทีมต้องแก้ schema เดียวกัน Generated client บางชนิดอาจไม่รู้จักค่าใหม่จนกว่าจะ regenerate จึงควรให้ schema กลางกำหนดรูปแบบ ส่วนแต่ละ operation ระบุ codes ที่มันอาจคืนผ่าน description และ examples:
ErrorCode:
type: string
pattern: '^[A-Z][A-Z0-9_]*$'
description: >
Stable machine-readable error code owned by the operation's domain.
หาก API มี error codes เป็นชุดปิดจริง และการเพิ่มค่าใหม่ต้องออก API version ใหม่ จึงค่อยประกาศ string enum ระดับ domain หรือ operation ได้ อ่านแนวทาง schema เพิ่มเติมได้จาก OpenAPI Specification ส่วน API ที่เลือกใช้ Problem Details สามารถศึกษาการแยก HTTP status, machine-readable problem type และ extension fields ได้จาก RFC 9457
OpenAPI เป็น Source of Truth ของ Response
แม้องค์กรจะมี Go package สำหรับเขียน response แล้ว Go struct ก็ไม่ควรเป็นหลักฐานเพียงชิ้นเดียวของ wire contract
ลองคิดจากมุม client ที่ไม่ได้เขียนด้วย Go Client ไม่ได้ import httpresponse.Envelope แต่เห็น JSON
ผ่าน network สิ่งที่ client ต้องการคือ schema ที่อธิบายว่า field ใดต้องมี, field ใดอาจเป็น null,
error code มีความหมายอย่างไร และ response สามารถเปลี่ยนแบบใดได้บ้าง
จึงควรกำหนด response contract ใน OpenAPI:
components:
schemas:
ErrorDetail:
type: object
required: [code, details]
properties:
code:
$ref: '#/components/schemas/ErrorCode'
details:
type: string
ErrorResponse:
type: object
required: [success, message, data, error]
properties:
success:
type: boolean
enum: [false]
message:
type: string
data:
nullable: true
error:
$ref: '#/components/schemas/ErrorDetail'
Shared Go module มีหน้าที่ช่วยให้ service ทำตาม contract นี้ได้ง่าย เช่น map application error, mask internal cause และ serialize response อย่างสม่ำเสมอ แต่ module ไม่ควรสร้าง schema อีกชุดหนึ่ง ซึ่งสามารถเปลี่ยนแยกจาก OpenAPI ได้
Quality gate สำหรับ response standard ควรตรวจอย่างน้อยว่า:
- non-success responses อ้าง schema มาตรฐาน
- documented error codes มีตัวอย่างและความหมาย
- generated types ถูก regenerate โดย tool version ที่ pin แล้ว
- contract tests เรียก endpoint จริงและตรวจ success/failure responses
- unknown internal errors ไม่รั่ว cause chain
- validation errors ไม่ echo request payload หรือ schema internals
- client ไม่ต้อง parse human-readable message เพื่อตัดสิน behavior
Logging Standard คือ Schema และ Policy
การกำหนดว่า “ทุก service ต้องใช้ logger package เดียวกัน” ยังไม่ตอบว่าองค์กรต้องการความสม่ำเสมอเรื่องใด
สิ่งที่ operator ต้องการจริงอาจเป็นการค้นหาทุก log ด้วย field ชุดเดียวกัน, เชื่อม log กับ trace ได้ และมั่นใจว่า token จะไม่ออกไปยัง log sink ดังนั้นมาตรฐาน logging ควรเริ่มจาก schema และ policy ก่อนเลือก implementation
log/slog มี Logger, Handler, level และ structured attributes อยู่แล้ว องค์กรไม่จำเป็นต้องสร้าง
logging framework ใหม่ทั้งหมด Shared module ควรเพิ่มเฉพาะ policy ที่ standard library ไม่รู้ เช่น:
- base fields:
service,version,environment - correlation fields:
trace_id,span_id,request_id - JSON output สำหรับ deployed environment
- redaction ของ credential และข้อมูลส่วนบุคคล
- field naming และ type ที่สม่ำเสมอ
- policy เมื่อ handler หรือ remote sink ล้ม
- test fixtures สำหรับตรวจ log record
logger, err := logging.New(
os.Stdout,
logging.Config{
Level: slog.LevelInfo,
Service: "inventory-api",
Environment: "production",
},
)
if err != nil {
return fmt.Errorf("creating logger: %w", err)
}
Constructor รับ io.Writer เพื่อให้ test output ได้ และไม่ควรอ่าน environment, เรียก os.Exit
หรือเปลี่ยน global state โดยซ่อนอยู่ภายใน การเรียก slog.SetDefault ควรเป็นการตัดสินใจของ composition root
ซึ่งเป็นเจ้าของ lifecycle ของ process
ในขณะเดียวกัน shared module ไม่ควรตัดสินว่า business event ใดต้องถูก log เพราะ domain owner เป็นผู้รู้ว่า การสร้าง reservation, การปฏิเสธ order หรือการเปลี่ยนสถานะใดมีความหมายทางธุรกิจ
logger.InfoContext(
ctx,
"inventory reservation created",
slog.String("reservation_id", reservation.ID),
slog.String("status", string(reservation.Status)),
)
Package กลางกำหนดรูปแบบและความปลอดภัย ส่วน application กำหนดความหมายของเหตุการณ์ อ่าน contract
ของ structured record และ handler ได้จาก log/slog
Private Repository ไม่เปลี่ยน Compatibility Rule
Package ใต้ internal/ เหมาะกับ code ที่ยังใช้ภายใน module เดียว เพราะ Go ป้องกันไม่ให้ repository อื่น
import package นั้นโดยตรง ทีมจึงสามารถเปลี่ยน interface หรือย้าย implementation ได้โดยไม่ทำให้ external
consumer compile ไม่ผ่าน
เมื่อ code ถูกย้ายออกจาก internal และเผยแพร่ให้ repository อื่น import สถานะของมันเปลี่ยนทันที
ทุก exported name, default และ error behavior กลายเป็นสิ่งที่ consumer อาจพึ่งพา ถึง repository
จะเป็น private ก็ตาม
ตัวอย่าง private module:
module git.example.com/engineering/go-api-contract
go 1.25.0
Consumer ระบุ private module prefix และเลือก version ที่ชัดเจน:
go env -w GOPRIVATE='git.example.com/engineering/*'
go get git.example.com/engineering/go-api-contract@v0.2.0
GOPRIVATE บอก Go ว่า module path นี้เป็น private และไม่ควรถูกส่งไปยัง public proxy หรือ public
checksum database ส่วน authentication ต้องจัดการผ่าน SSH, credential helper หรือ token ที่มีสิทธิ์เท่าที่จำเป็น
อย่าใส่ access token ลงใน go.mod, import path หรือ source code และ credential ใน CI ควรมีเพียงสิทธิ์อ่าน
repository ที่จำเป็น อ่านรายละเอียดได้จาก
Go Modules Reference — Private modules
Version คือช่องทางสื่อสารกับ Consumer
เมื่อ service import private module มันไม่ได้รับเพียง source code แต่รับ contract และความเสี่ยงจากการเปลี่ยนแปลง ของ module นั้นด้วย Version จึงเป็นช่องทางที่ maintainer ใช้บอก consumer ว่าการ upgrade ครั้งนี้มีผลอย่างไร
v0.x.xสำหรับช่วงทดลองที่ interface ยังเปลี่ยนได้v1.x.xเมื่อประกาศ stability และ compatibility policy- patch release สำหรับ bug fix ที่ไม่เปลี่ยน public contract
- minor release สำหรับความสามารถใหม่ที่ backward-compatible
- major release สำหรับ breaking change ที่หลีกเลี่ยงไม่ได้
Go กำหนดให้ major version ตั้งแต่ v2 ปรากฏใน module path:
module git.example.com/engineering/go-api-contract/v2
ห้ามย้าย tag เดิมไปยัง commit ใหม่เพื่อแก้ release ที่ผิด เพราะ consumer อาจดาวน์โหลด version เดิมไปแล้ว
และ go.sum บันทึก checksum ของเนื้อหานั้นไว้ วิธีที่ถูกต้องคือแก้ปัญหาแล้วออก version ใหม่
แนวทางตั้งแต่ทดลองด้วย v0 จนถึง stable v1 และ breaking major version อธิบายไว้ใน
Module release and versioning workflow
Governance สำคัญกว่า Repository Location
การสร้าง repository ชื่อ go-common ไม่ได้ทำให้ code ภายในกลายเป็นมาตรฐานโดยอัตโนมัติ หากไม่มี owner,
release notes และไม่มีใครรู้ว่าการเปลี่ยน interface ต้องผ่านใคร repository กลางจะกลายเป็น code ที่ทุกคน
พึ่งพาแต่ไม่มีใครกล้าแก้
Module กลางต้องมี operating model อย่างน้อยดังนี้:
- owner และ backup owner
- CODEOWNERS หรือ review policy
- supported Go versions
- compatibility และ deprecation policy
- release notes หรือ changelog
- security advisory และ upgrade owner
- test, vet, vulnerability และ compatibility gates
- working examples สำหรับ consumer
- SLA ของการ review issue หรือ change request
- วิธีเสนอ exception เมื่อ standard ไม่เหมาะกับ workload
Baseline CI อาจเริ่มจาก:
gofmt -w .
go test ./...
go vet ./...
go mod tidy -diff
go mod verify
govulncheck ./...
Shared module ควรมี tests ที่ใช้ interface แบบเดียวกับ consumer จริง ไม่ใช่ทดสอบเฉพาะ helper ภายใน package เพราะสิ่งที่ต้องรักษาคือ behavior ที่ repository อื่นมองเห็น
ทีม platform หรือ enablement อาจเป็น maintainer แต่ไม่ควรเป็น owner ของทุก domain decision Consumer teams ต้องมีส่วน review interface ที่ตนเองจะพึ่งพา มิฉะนั้น module กลางจะกลายเป็นสิ่งที่ประกาศจากส่วนกลางแต่ไม่มี ใครต้องการใช้
Rollout แบบ Pilot ก่อนการบังคับใช้
เมื่อ module รุ่นแรกพร้อมแล้ว ไม่ควรประกาศให้ทุก service เปลี่ยนมาใช้พร้อมกัน Interface ที่ดูเหมาะใน repository ต้นทางอาจยังซ่อน assumption บางอย่าง เช่น ทุก service ใช้ Chi, ทุก process ทำงานบน Lambda หรือทุก error เดินทางผ่าน HTTP
ควรเลือกทดลองกับ services จำนวนเล็กน้อยก่อน เช่น service หนึ่งที่ทำงานเป็น HTTP API และอีก service ที่มี worker อยู่ด้วย ความแตกต่างของ consumer จะช่วยเปิดเผยว่า module กำลังซ่อน policy ที่ควรเป็นของ caller หรือไม่
ลำดับการนำไปใช้ที่ปลอดภัยมีดังนี้:
- ทำ inventory ของ implementations ที่มีอยู่
- แยก behavior ที่เหมือนจริงออกจากชื่อที่บังเอิญคล้ายกัน
- เลือก consumer pilot จำนวน 2 services ที่มี runtime ต่างกันพอจะทดสอบ interface
- เขียน contract tests และ golden outputs ก่อน extract
- publish
v0.1.0 - migrate pilot โดยแยก structural change ออกจาก behavior change
- เก็บ friction จาก call sites และ operations
- publish
v0.xจน interface นิ่ง - ประกาศ
v1.0.0พร้อม compatibility policy - บังคับเฉพาะ code ใหม่ก่อน แล้วค่อยวาง deprecation plan สำหรับของเดิม
วิธีนี้ทำให้ความผิดพลาดเกิดในวงเล็กและยังแก้ interface ได้ก่อนที่ module จะมี consumer จำนวนมาก เมื่อ module นิ่งแล้วจึงค่อยกำหนดให้ service ใหม่ใช้เป็น default และวางแผนย้ายระบบเดิมตามความเสี่ยง และจังหวะที่เหมาะสม
อย่าเริ่มด้วยการ copy implementation ที่ดูดีที่สุดไปยัง central repository แล้วสั่งทุกทีมใช้ เพราะ implementation นั้นอาจซ่อน assumptions ของ service เดิม เช่น runtime, framework, authentication, retry หรือ data classification การ extract ที่ปลอดภัยต้อง neutralize assumptions เหล่านี้ก่อน และปล่อย adapter เฉพาะ service ไว้ที่ composition root
สิ่งที่ไม่ควรกลายเป็น Shared Module
การมีมาตรฐานมากเกินไปอาจสร้าง coupling รูปแบบใหม่ องค์กรจึงต้องรู้ด้วยว่าสิ่งใดควรปล่อยให้แต่ละ service ตัดสินใจเอง
ระวัง anti-pattern ต่อไปนี้:
platformmodule ที่รวม HTTP, database, Temporal, logging และ business errors- package
commonที่รับ type จากทุก domain - shared config ที่กำหนด pool size หรือ timeout เดียวให้ทุก workload
- logger wrapper ที่สร้าง interface ใหญ่กว่า
slog.Logger - error type กลางที่รวม domain code ทั้งองค์กรไว้ใน switch เดียว
- package ที่อ่าน environment หรือเปิด network connection ใน
init() - template ที่ถูก copy แล้วเรียกว่า library
- module ที่ไม่มี owner แต่ทุก service ถูกบังคับให้ import
- release ที่เปลี่ยน behavior โดยไม่เพิ่ม version
- abstraction ที่เกิดจาก implementation เพียงตัวเดียวและยังไม่มี consumer จริง
Standardization มี blast radius
Bug ใน local package กระทบ service เดียว แต่ bug ใน shared module อาจกระทบทุก consumer Module กลางจึงต้องมี test, staged rollout, release discipline และทาง rollback ที่เข้มกว่าปกติ
Review Lab: Library, Tooling หรือ Local Code
สมมติองค์กรมี code ต่อไปนี้ซ้ำในหลาย services:
- สร้าง JSON
slog.Handlerพร้อมservice,environment,trace_idและ redaction - กำหนด
ORDER_NOT_FOUNDและPAYMENT_DECLINED - ตั้ง MySQL
MaxOpenConnsเป็น20 - แปลง OpenAPI validation error เป็น client-safe details
- ตรวจว่าทุก error response มี
success,message,data,error - สร้าง UUIDv7 ด้วย function wrapper 1 บรรทัด
- กำหนด repository layout สำหรับ service ใหม่
คำตอบที่มีเหตุผลควรแยกได้ว่า:
- ข้อ 1 และ 4 เป็น shared module candidates เพราะซ่อน behavior ที่ซับซ้อนหลัง interface เล็ก
- ข้อ 2 เป็น domain-owned vocabulary ภายใต้รูปแบบมาตรฐาน
- ข้อ 3 เป็น workload-specific configuration ไม่ใช่ค่ากลาง
- ข้อ 5 เป็น OpenAPI contract + CI conformance test และอาจมี runtime helper ประกอบ
- ข้อ 6 อาจใช้ convention โดยตรงจนกว่าจะมี invariant มากกว่าการเรียก library เดิม
- ข้อ 7 เป็น template ไม่ใช่ runtime dependency
Checklist ก่อนประกาศ Organizational Standard
- ปัญหาเป็น contract drift หรือเพียง code คล้ายกัน
- มี consumer จริงอย่างน้อย 2 ตัว
- แยก convention, contract, tooling, template และ shared module แล้ว
- module ตั้งชื่อตาม capability ไม่ใช่
commonหรือplatform - interface เล็กและซ่อน behavior ที่มีคุณค่า
- domain vocabulary ยังมี domain owner
- public error code เป็น stable string ไม่ใช่เลขลำดับหรือค่า
iota - OpenAPI เป็น source of truth ของ wire contract
- logging standard ครอบคลุม schema, correlation และ redaction
- ไม่มี hidden
init, environment read หรือ process exit - private module ใช้ version tag และ
GOPRIVATE - มี owner, compatibility policy และ release process
- pilot ก่อนบังคับใช้กับทุก repository
- consumer มี migration และ rollback path
หลักตัดสินสุดท้าย
Standardize สิ่งที่ต้องเหมือนกัน, share behavior ที่ต้องแก้พร้อมกัน, ใช้ template กับโครงเริ่มต้น และปล่อยสิ่งที่ขึ้นกับ domain หรือ workload ไว้ใกล้ owner ของมัน
อ่านเพิ่ม: Organizing a Go module,
Go Modules Reference — Private modules,
Developing and publishing modules,
Module release and versioning workflow และ
log/slog