บทที่ 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เรื่องที่ต้องใช้ judgmentnaming, package layout, error message style
Contractสิ่งที่ consumer ต้องพึ่งพาOpenAPI schema, error code, log field vocabulary
Toolingกฎที่ตรวจแบบ deterministic ได้formatter, linter, architecture test, CI
Shared modulebehavior ที่ต้องแก้ครั้งเดียวแล้วทุก 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 ให้ถามคำถามต่อไปนี้:

  1. มี consumer จริงอย่างน้อย 2 ตัวหรือยัง
  2. Behavior ส่วนใดต้องเหมือนกัน และส่วนใดควรต่างตาม workload
  3. Interface นิ่งพอให้ทีมอื่นพึ่งพาหรือยัง
  4. ถ้าลบ module นี้ complexity จะหายไป หรือกระจายกลับไปทุก consumer
  5. Module มี owner ที่ review, release และตอบคำถามได้หรือไม่
  6. มี test ที่พิสูจน์ behavior ผ่าน interface ที่ consumer ใช้หรือไม่
  7. การเพิ่ม 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
Codecontract ที่ client ใช้ตัดสินใจORDER_NOT_FOUND
HTTP statusความหมายระดับ HTTP404

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 หรือไม่

ลำดับการนำไปใช้ที่ปลอดภัยมีดังนี้:

  1. ทำ inventory ของ implementations ที่มีอยู่
  2. แยก behavior ที่เหมือนจริงออกจากชื่อที่บังเอิญคล้ายกัน
  3. เลือก consumer pilot จำนวน 2 services ที่มี runtime ต่างกันพอจะทดสอบ interface
  4. เขียน contract tests และ golden outputs ก่อน extract
  5. publish v0.1.0
  6. migrate pilot โดยแยก structural change ออกจาก behavior change
  7. เก็บ friction จาก call sites และ operations
  8. publish v0.x จน interface นิ่ง
  9. ประกาศ v1.0.0 พร้อม compatibility policy
  10. บังคับเฉพาะ 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 ต่อไปนี้:

  • platform module ที่รวม 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:

  1. สร้าง JSON slog.Handler พร้อม service, environment, trace_id และ redaction
  2. กำหนด ORDER_NOT_FOUND และ PAYMENT_DECLINED
  3. ตั้ง MySQL MaxOpenConns เป็น 20
  4. แปลง OpenAPI validation error เป็น client-safe details
  5. ตรวจว่าทุก error response มี success, message, data, error
  6. สร้าง UUIDv7 ด้วย function wrapper 1 บรรทัด
  7. กำหนด 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