Background
- Inconsistent project structures per service made code hard to follow.
- No shared conventions made common module/CI reuse difficult, reducing productivity.
- Onboarding was challenging, especially for Go newcomers.
- Our team has now matured in using Go and has built internal best practices.
- We want to establish conventions for better maintainability and clarity across services.
Proposed Project Structure
Sections marked with * are mandatory. This structure assumes MSA-style projects rather than large monoliths.
Example Structure (Domain: match sampling)
.
├── *docs
│ ├── *swagger.yaml
│ ├── sequence.md
│ └── architecture.md
├── *cmd
│ └── *main.go
├── pkg
│ ├── file_parser.go
│ └── time_convertor.go
└── *internal
├── *handler
│ ├── *v1/sampling_handler.go
│ ├── v2/sampling_handler.go
│ ├── server.go
│ ├── health_handler.go
│ ├── swagger_handler.go
│ └── auth_middleware.go
├── data
│ ├── mysqldb.go
│ ├── redis.go
│ ├── feature_event_producer.go
│ ├── match_repository.go
│ └── nass_api.go
├── *service
│ ├── kda_sampler.go
│ ├── match_sampling_usecase.go
│ └── kda_sampler_test.go
├── logger.go
├── constants.go
└── *config.go
├── *gitlab-ci.yml
├── *go.mod
├── *go.sum
└── *README.md

| Section | Required | Description |
|---|---|---|
| docs | ✔ | Project-level diagrams and specs |
| cmd | ✔ | Entry point for DI & execution |
| pkg | optional | Utility modules safe for external reuse |
| internal | ✔ | Core domain logic, hidden from outside |
| handler | ✔ | HTTP/gRPC/Kafka handlers (versioned) |
| data | optional | Database, external API, Kafka interactions |
| service | ✔ | Business logic per SRP, unit tested |
| root files | ✔ | CI, readme, mod/sum files |
Constant Convention
- Use
PascalCasefor constants shared across packages. - Use
camelCasefor internal/private constants. - If a private constant must be exposed, wrap it via a public method.
Data Model Convention
- Define models close to their use (not in shared folders).
- Use DTOs between layers, and convert as needed.
- For 2+ arguments, use structs.
- Keep validation logic in methods for readability and testability.
// AS-IS
if strings.HasPrefix(match.Version, "rc") && match.detail == "test" { ... }
// TO-BE
if match.IsTest() { ... }
Test Conventions
Testing business logic is mandatory, not optional. You should write test cases for already-defined errors in a concise yet detailed manner.
Deterministic Asynchronous Unit Testing
Avoid relying on time.Sleep or blindly logging after async execution without assertions.
Prevent flaky tests by leveraging Dependency Injection (DI) and assert.Eventually.
1. Injecting a Logger
// NewQueue creates a Queue instance responsible for business logic
func NewQueue(
config Config,
httpClient *http.Client,
logger *zerolog.Logger,
) (queue Queue, err error) {
// The queue will execute the thread executor when Start() is called.
queue = Queue{
config: config,
client: httpClient,
logger: logger,
quitChan: make(chan struct{}),
}
return
}
2. Testing Output
- Test case for queue failure logging
t.Run("Logs failure correctly when queue processing fails", func(t *testing.T) {
// given
var buffer bytes.Buffer
... inject logger with buffer as output
// when
... execute async task
event1, err := queue.Push([]byte(validJSON1))
assert.NoError(t, err)
event2, err := queue.Push([]byte(validJSON2))
assert.NoError(t, err)
// then
assert.Eventually(t, func() bool {
output := buffer.String()
return strings.Contains(output, event1.TraceID().String()) &&
strings.Contains(output, event2.TraceID().String()) &&
strings.Contains(output, `"success":false`)
}, 1*time.Second, 10*time.Millisecond)
})
- Test case for queue success logging
t.Run("Logs success correctly when queue processes successfully", func(t *testing.T) {
// given
var buffer bytes.Buffer
... inject logger with buffer as output
// when
... execute async task
event1, err := queue.Push([]byte(validJSON1))
assert.NoError(t, err)
event2, err := queue.Push([]byte(validJSON2))
assert.NoError(t, err)
// then
assert.Eventually(t, func() bool {
output := buffer.String()
return strings.Contains(output, event1.TraceID().String()) &&
strings.Contains(output, event2.TraceID().String()) &&
strings.Contains(output, `"success":true`)
}, 1*time.Second, 10*time.Millisecond)
})
