Câu hỏi đầu tiên của mọi người mới: "dự án Go nên có cấu trúc thư mục thế nào?" Hình dung nó như sắp một xưởng đồ nghề: bạn không đóng cái tủ bốn mươi ngăn cho một hộp đồ nghề, và cách gom đồ đúng đắn hoá ra không như nhiều người tưởng.

Câu trả lời không được ưa chuộng: bắt đầu bằng một tệp.

Không có chuẩn chính thức

Kho golang-standards/project-layout trên GitHub có hơn bốn mươi nghìn sao và không phải chuẩn chính thức — nhóm Go đã nhiều lần nói vậy. Nó mô tả bố cục của các dự án rất lớn, và chép nó vào một dịch vụ CRUD là cách nhanh nhất để có bảy tầng thư mục cho hai trăm dòng mã — đóng tủ bốn mươi ngăn cho vài cái tua vít.

Bố cục đúng phụ thuộc kích thước. Đây là ba mức tôi thấy hợp lý.

Mức 1: một package

duan/
├── go.mod
├── main.go
├── kho.go
└── kho_test.go

Mọi thứ trong package main. Không có import nội bộ, không có vòng phụ thuộc, không phải nghĩ gì — một cái bàn thợ, mọi đồ để trên đó.

Mức này đủ cho công cụ dòng lệnh và dịch vụ nhỏ — vài nghìn dòng vẫn hoàn toàn ổn. Đừng tách sớm.

Mức 2: tách theo miền

duan/
├── go.mod
├── main.go
├── donhang/       nghiệp vụ đơn hàng
├── khachhang/     nghiệp vụ khách hàng
└── kho/           truy cập dữ liệu

Tách khi package main bắt đầu khó đọc. Nguyên tắc quan trọng nhất:

Tách theo miền nghiệp vụ, không tách theo tầng kỹ thuật.

donhang/ chứa cả kiểu, logic và truy cập dữ liệu của đơn hàng — như một "góc pha cà phê" để chung cà phê, cối xay, phin và cốc. Đừng tạo models/, services/, repositories/ — đó là bố cục Java, là gom theo vật liệu (tất cả đồ kim loại một ngăn, đồ thuỷ tinh một ngăn), và trong Go nó dẫn tới việc mọi thay đổi phải sửa ba package.

Dấu hiệu bạn đang tách sai: thêm một trường vào một entity mà phải sửa bốn thư mục — làm một ly cà phê mà phải mở bốn ngăn kéo.

Mức 3: nhiều binary và ranh giới rõ

duan/
├── go.mod
├── cmd/
│   ├── api/main.go        binary thứ nhất
│   └── worker/main.go     binary thứ hai
├── internal/
│   ├── donhang/
│   ├── kho/
│   └── http/
└── pkg/                   chỉ khi CÓ người ngoài dùng thật

cmd/ khi có nhiều binary. Mỗi thư mục con là một package main. Nội dung phải mỏng — đọc cấu hình, nối các mảnh, gọi Run(). Logic nằm ở internal/.

internal/ cho mọi thứ không phải API công khai. Trình biên dịch ép buộc điều này, như bài 3 đã nói.

pkg/ là thư mục gây tranh cãi nhất. Nó chỉ có nghĩa khi bạn thật sự xuất bản thư viện cho người khác. Với dịch vụ nội bộ, pkg/ không thêm gì so với đặt package ở gốc — mà lại thêm một tầng thư mục.

Lời khuyên của tôi: bỏ qua pkg/ trừ khi có lý do cụ thể.

Ba dấu hiệu đã đến lúc tách package

Tệp quá dài để tìm thứ gì đó. Không có con số ma thuật; khi bạn bắt đầu dùng tìm kiếm thay vì cuộn, đó là lúc — khi bạn phải lục thay vì với tay.

Muốn giấu chi tiết cài đặt. Bài 19 đã nói: chữ thường chỉ bảo vệ ở ranh giới package. Cần giấu thật thì phải có package riêng.

Có hai nhóm thay đổi vì lý do khác nhau. Đây là tiêu chí tốt nhất, và nó đúng với mọi ngôn ngữ.

Ngược lại, đừng tách chỉ vì tệp có nhiều hơn 200 dòng. Tệp dài trong Go là bình thường — thư viện chuẩn có tệp hàng nghìn dòng.

Phụ thuộc vòng là lỗi biên dịch

Go cấm package A import B trong khi B import A. Không có ngoại lệ, không có cờ để tắt.

Nghe khắt khe, nhưng nó ép bạn suy nghĩ về hướng phụ thuộc ngay từ đầu. Ba cách gỡ khi gặp:

Tách phần chung ra package thứ ba — thường là các kiểu dữ liệu.

Đảo chiều bằng interface. Package cấp thấp khai interface cho thứ nó cần, package cấp cao cài đặt. Đây là chỗ "interface thuộc về phía người dùng" ở bài 13 trả công.

Gộp lại. Nếu hai package phụ thuộc vòng, có khi chúng vốn là một.

Tệp trong package

Trong một package, cách chia tệp là tự do. Quy ước hay dùng:

kho/
├── kho.go          kiểu chính và API
├── truy_van.go     nhóm chức năng
├── kho_test.go     test
└── doc.go          chỉ chứa comment tài liệu (tuỳ chọn)

Tệp _test.go không được biên dịch vào binary. Tệp có hậu tố _linux.go, _windows.go chỉ biên dịch trên nền tảng tương ứng — cơ chế build tag ở bài 54.

Nếu chỉ soi một thứ sau bài này, đếm xem xưởng của bạn đang kéo vào bao nhiêu đồ mượn, trong ba mươi giây:

go list -deps ./... | grep -v '^\(internal/\)\?[a-z]*$' | grep -c .

Đếm số package bên ngoài mà dự án bạn kéo vào. Và:

go mod graph | wc -l

Nếu con số thứ hai lớn hơn bạn tưởng nhiều lần, dự án của bạn đang mang theo một cây phụ thuộc mà không ai nhìn tới — đúng thứ bài 59 sẽ nói khi bàn về lỗ hổng bảo mật.

Mẫu số chung

Gom mã theo cái nó nói về — miền nghiệp vụ — chứ không theo loại kỹ thuật của nó — tầng. Package-theo- tính-năng thắng package-theo-tầng ở mọi hệ sinh thái (models/services/repositories của Java, gom-theo- loại của React, và đối lại là bounded context của DDD, ý "kiến trúc biết hét lên nó là gì"), và phép thử ở đâu cũng y hệt: một thay đổi nên rơi vào một chỗ, không lan ra bốn cây thư mục song song — để chung những thứ thay đổi cùng nhau. Và đừng đổ khuôn cấu trúc trước khi có gì để đựng: bắt đầu phẳng, chỉ tách khi có tín hiệu thật — bạn đang lục thay vì với, bạn cần giấu nội bộ, hoặc hai phần thay đổi vì hai lý do — vì cấu trúc áp đặt trước-khi-cần là chi phí thuần, đúng tinh thần YAGNI áp cho kiến trúc (và là lý do chép một bố cục "chuẩn" bốn mươi nghìn sao vào một dịch vụ nhỏ lại phản tác dụng).

Điều thứ hai: đồ thị phụ thuộc phải chỉ một hướng — một cái vòng là tín hiệu thiết kế, không phải phiền toái. Go biến nó thành lỗi biên dịch cứng; nguyên tắc (Acyclic Dependencies Principle) là phổ quát, và import vòng trong Python hay JS cắn đau y hệt mà không có trình biên dịch đỡ cho. Thuốc chữa luôn là ba: tách phần chung ra, đảo chiều bằng interface (bên dùng khai cái nó cần — đảo-ngược-phụ-thuộc), hoặc gộp hai thứ vốn chưa từng thật sự tách rời. Phụ thuộc nên chảy về phía ổn định, và khi nó tạo vòng, cấu trúc đang mách bạn rằng chỗ tách đã sai.

Ngày mai: chuỗi, byte và rune — vì sao len("Tiếng Việt") trả về 14.

Bài tập làm thử

Bài 1 (đọc hiểu). Bài viết phản đối cấu trúc thư mục kiểu models/, services/, repositories/ cho một dự án Go nhỏ. Giải thích lý do dựa trên ẩn dụ "xưởng đồ nghề" mà bài viết dùng, và nêu dấu hiệu cụ thể cho thấy một dự án đang tách package sai cách.

Đáp án

Cấu trúc models/services/repositories gom mã theo loại kỹ thuật (vật liệu) — giống việc xếp mọi đồ kim loại vào một ngăn, mọi đồ thuỷ tinh vào ngăn khác — thay vì gom theo công việc/miền nghiệp vụ. Hệ quả là một thay đổi nghiệp vụ đơn giản (ví dụ thêm một trường vào entity DonHang) buộc phải sửa nhiều thư mục khác nhau (model, service, repository) cùng lúc. Dấu hiệu cụ thể bài viết nêu: "thêm một trường vào một entity mà phải sửa bốn thư mục" — như phải mở bốn ngăn kéo chỉ để pha một ly cà phê.

Bài 2 (sửa lỗi/thiết kế). Package donhang cần gọi một hàm trong package khachhang để kiểm tra khách hàng có tồn tại không, đồng thời package khachhang cũng cần gọi một hàm trong package donhang để lấy lịch sử đơn hàng của khách — tạo ra phụ thuộc vòng. Nêu ba cách gỡ theo bài viết, và chọn một cách áp dụng cụ thể cho tình huống này.

Đáp án

Ba cách gỡ theo bài viết: (1) tách phần chung ra package thứ ba (thường là các kiểu dữ liệu); (2) đảo chiều bằng interface — package cấp thấp khai interface cho thứ nó cần, package cấp cao cài đặt; (3) gộp lại nếu hai package vốn dĩ nên là một.

Áp dụng cho tình huống: dùng cách (2) — ví dụ donhang khai một interface KhachHangKiemTra với method TonTai(id string) bool, và package khachhang cài đặt interface đó khi cần dùng, hoặc ngược lại tuỳ package nào là "trung tâm" hơn. Đây chính là ứng dụng của nguyên tắc "interface thuộc về phía người dùng" mà bài viết dẫn lại từ bài 13.

Bài 3 (vận dụng). Một dự án Go hiện có cấu trúc mức 1 (một package main cho mọi thứ, vài nghìn dòng). Đội phát triển bắt đầu thêm một binary worker chạy nền riêng biệt với API server hiện có. Vẽ (bằng cây thư mục) cấu trúc mức 3 phù hợp cho tình huống này, gồm cmd/, internal/.

Đáp án
duan/
├── go.mod
├── cmd/
│   ├── api/main.go        binary API server
│   └── worker/main.go     binary worker chạy nền
└── internal/
    ├── donhang/           nghiệp vụ
    ├── kho/               truy cập dữ liệu
    └── http/              handler dùng chung cho api

cmd/api/main.go và cmd/worker/main.go đều là package main riêng biệt nhưng mỏng — chỉ đọc cấu hình, nối các mảnh, gọi Run(). Logic nghiệp vụ thật sự nằm ở internal/, được cả hai binary dùng chung. pkg/ không cần thiết trừ khi có kế hoạch xuất bản thư viện cho bên ngoài dùng thật.

Bài 4 (bẫy/đánh đổi). Một lập trình viên mới vào dự án Go, thấy tệp donhang.go dài 1500 dòng, lập tức quyết định tách nó thành 5 package nhỏ vì "tệp dài hơn 200 dòng là dấu hiệu xấu". Giải thích tại sao lý do này không đúng theo bài viết, và nêu ba dấu hiệu thật sự cho thấy đã đến lúc tách package.

Đáp án

Bài viết nói rõ: "đừng tách chỉ vì tệp có nhiều hơn 200 dòng" — tệp dài trong Go là bình thường, ngay cả thư viện chuẩn cũng có tệp hàng nghìn dòng. Không có con số ma thuật cho độ dài tệp. Ba dấu hiệu thật sự nên tách: (1) tệp quá dài đến mức bạn phải tìm kiếm thay vì cuộn để định vị thứ gì đó ("lục thay vì với tay"); (2) muốn giấu chi tiết cài đặt thật sự (chữ thường chỉ bảo vệ ở ranh giới package, nên cần giấu thật thì phải có package riêng); (3) có hai nhóm thay đổi vì lý do khác nhau trong cùng một tệp/package — đây là tiêu chí tốt nhất theo bài viết, đúng ở mọi ngôn ngữ.

Bài 5 (đọc hiểu/vận dụng lệnh). Giải thích hai lệnh sau dùng để làm gì, và tại sao chạy chúng có ích để phát hiện vấn đề cấu trúc dự án:

go list -deps ./... | grep -v '^\(internal/\)\?[a-z]*$' | grep -c .
go mod graph | wc -l
Đáp án

Lệnh đầu đếm số package bên ngoài (không phải package nội bộ đơn giản) mà toàn bộ dự án đang kéo vào — cho biết dự án đang "mượn" bao nhiêu đồ từ bên ngoài xưởng. Lệnh thứ hai đếm tổng số dòng trong đồ thị phụ thuộc module (go mod graph), tức quy mô toàn bộ cây phụ thuộc bắc cầu (transitive). Nếu con số này lớn hơn nhiều so với dự đoán, đó là dấu hiệu dự án đang mang theo một cây phụ thuộc khổng lồ mà không ai thực sự nhìn tới hết — mỗi phụ thuộc bắc cầu đều là một bề mặt tấn công tiềm ẩn (liên hệ tới chủ đề lỗ hổng bảo mật trong phụ thuộc mà bài viết nhắc sẽ bàn ở bài sau).