Instruction file imported from chatbotgang/ai-coding-workshop-250712 (
.github/instructions/go.instructions.md). Copyright stays with the author.
Go Development Guide in CL
This document provides comprehensive guidance for Go development in Crescendo Lab, covering architecture patterns, coding style, and best practices.
Table of Contents
- Architecture Patterns
- Package Structure
- Code Organization
- Naming Conventions
- Types and Structs
- Functions and Methods
- Error Handling
- Concurrency
- Testing
- Performance Considerations
- Logging and Observability
- Documentation
- Project Structure
Architecture Patterns
Clean Architecture
Go project in CL follows a clean architecture pattern with distinct layers:
-
Domain Layer (
internal/domain/)- Contains business entities and logic
- Independent of external frameworks and databases
- Defines interfaces that are implemented by outer layers
-
Application Layer (
internal/app/)- Contains application services that orchestrate domain entities
- Implements use cases of the system
- Depends on domain layer, but not on external frameworks
-
Adapter Layer (
internal/adapter/)- Implements interfaces defined in the domain layer
- Connects the application to external systems (databases, message brokers, etc.)
- Contains concrete implementations of repositories and services
-
Router Layer (
internal/router/)- Entry points to the application (HTTP handlers, CLI commands)
- Depends on application services
- Translates external requests to internal application calls
Common Patterns
Interface Design
- Define interfaces in the layer that uses them, not in the layer that implements them
- Keep interfaces small and focused on a single responsibility
- Use dependency injection to provide implementations
// In domain layer
type MessageRepository interface {
WriteMessageBatch(context.Context, []message.Message) common.Error
GetMessage(ctx context.Context, channelType message.ChannelType, externalMessageID string) (*message.Message, common.Error)
}
// In adapter layer
type messageRepository struct {
// implementation details
}
Service Pattern
- Services should be stateless when possible
- Use parameter structs for service constructors with many dependencies
- Services should depend on interfaces, not concrete implementations
type GatewayServiceParam struct {
MessageRepository MessageRepository
ReportRepository ReportRepository
// other dependencies
}
func NewGatewayService(ctx context.Context, param GatewayServiceParam) *GatewayService {
return &GatewayService{
messageRepository: param.MessageRepository,
// initialize other dependencies
}
}
Repository Pattern
- Repositories abstract data access logic
- They should return domain entities, not data transfer objects
- Use custom error types for domain-specific errors
Factory Pattern
- Use factories to create complex domain entities or services
- Hide implementation details behind factory interfaces
type ChannelFactory interface {
Channel(context.Context, message.ChannelType) (message.Channel, common.Error)
}
Event-Driven Architecture
- Use event brokers for asynchronous communication between services
- Define clear event interfaces and payloads
- Use event-driven patterns for scalability and loose coupling
Package Structure
- Package names should be short, concise, and lowercase (e.g.,
message,gateway,common) - One package per directory
- Package name should match the directory name
- Use
internaldirectory for code that should not be imported by other projects
Code Organization
Package Design
- Keep packages focused on a single responsibility
- Avoid circular dependencies between packages
- Organize code by domain concept, not by technical function
File Organization
- Keep files to a reasonable size (under 500 lines if possible)
- Group related functionality in the same file
- Place interfaces in the same package as the code that uses them
- Use separate files for tests
Imports
- Group imports into standard library, external packages, and internal packages
- Within each group, imports should be alphabetically sorted
- Use blank lines to separate import groups
import (
"context"
"encoding/json"
"time"
"github.com/rs/zerolog"
"github.com/alecthomas/kingpin/v2"
"github.com/chatbotgang/medley/internal/domain/message"
)
Naming Conventions
- Use
CamelCasefor exported names (public) - Use
mixedCasefor non-exported names (private) - Use acronyms consistently (e.g.,
ID,URL,HTTP) - Constants should use
PascalCasewith descriptive prefixes - Interface names should not end with
-er(e.g.,MessageRepositorynotMessageRepositorier)
Types and Structs
- Define types at the top of the file, followed by constants, then variables
- Group related constants together
- Use struct tags for JSON serialization when needed
- For parameter structs, use the suffix
Param(e.g.,GatewayServiceParam)
type MessageStatus string
const (
MessageStatusProcessing MessageStatus = "PROCESSING"
MessageStatusSent MessageStatus = "SENT"
MessageStatusDelivered MessageStatus = "DELIVERED"
)
type Message struct {
MessageID string `json:"message_id"`
Status MessageStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
Functions and Methods
- Use descriptive function names that indicate what the function does
- Receiver variable should be a short, consistent name (e.g.,
sfor service) - Constructor functions should use
Newprefix (e.g.,NewGatewayService) - Return errors as the last return value
- Use named return parameters sparingly
func NewGatewayService(ctx context.Context, param GatewayServiceParam) *GatewayService {
return &GatewayService{
messageRepository: param.MessageRepository,
// ...
}
}
func (s *GatewayService) logger(ctx context.Context) *zerolog.Logger {
l := zerolog.Ctx(ctx).With().Str("component", "gateway-service").Logger()
return &l
}
Error Handling
Error Types
- Use custom error types from
commonpackage - Implement the error interface for custom error types
- Use error wrapping to add context to errors
- Check specific error types using errors.Is() and errors.As()
if err != nil {
return common.NewError(common.ErrorCodeInternal, "failed to process message", err)
}
Error Propagation
- Return errors to the caller rather than handling them internally when appropriate
- Log errors at the appropriate level (usually at the entry point)
- Include relevant context in error messages
- Don't use panic for normal error handling
Concurrency
- Use contexts for cancellation and timeouts
- Prefer channels and goroutines over mutexes when appropriate
- Always use proper synchronization when sharing data between goroutines
- Use goroutines for concurrent operations, but be mindful of resource usage
- Consider using worker pools for CPU-intensive or I/O-bound tasks
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
// Use ctx for operations that should be cancelled after timeout
Goroutine Management
- Always ensure goroutines can exit
- Use context for cancellation
- Be careful with infinite loops in goroutines
- Consider using errgroup for managing groups of goroutines
g, ctx := errgroup.WithContext(parentCtx)
for _, task := range tasks {
task := task // Create a new variable for the closure
g.Go(func() error {
return processTask(ctx, task)
})
}
if err := g.Wait(); err != nil {
// Handle error
}
Testing
Test Parallelization Rules
ALWAYS add t.Parallel() to test functions EXCEPT for adapter layer tests
✅ Use t.Parallel() for:
- Domain layer tests (
internal/domain/) - Pure business logic, no external dependencies - Application layer tests (
internal/app/) - Service tests with mocked dependencies - Router layer tests (
internal/router/) - HTTP handler tests with mocked services
func TestDomainFunction(t *testing.T) {
t.Parallel() // ✅ Always add for domain tests
// test implementation
}
func TestAppService(t *testing.T) {
t.Parallel() // ✅ Always add for app layer tests
// test implementation with mocks
}
❌ DO NOT use t.Parallel() for:
- Adapter layer tests (
internal/adapter/) - Integration tests coupled with third-party services, databases, or external systems
func TestRedisRepository(t *testing.T) {
// ❌ DO NOT add t.Parallel() for adapter tests
if testRedisHost == "" {
t.Skip("skip integration tests")
}
// integration test implementation
}
Why this rule?
-
Adapter layer contains integration tests that often:
- Connect to shared external resources (Redis, databases, APIs)
- Have resource contention issues when run in parallel
- May have timing dependencies or cleanup requirements
- Could interfere with each other when accessing the same external systems
-
Other layers contain unit tests that:
- Use mocked dependencies
- Are isolated and independent
- Benefit greatly from parallel execution
- Have no shared state or external dependencies
Unit Tests
- Write tests for all exported functions and methods
- Use table-driven tests for testing multiple cases
- Mock external dependencies using interfaces
- Keep tests independent and idempotent
func TestSendMessage(t *testing.T) {
t.Parallel() // ✅ Add t.Parallel() for unit tests
tests := []struct {
name string
message message.Message
expectedError bool
}{
// Test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
})
}
}
Integration Tests
- Use conditional test skipping for tests that require external credentials or services:
if testLineToken == "" || testLineMemberID == "" { t.Skip("skip integration tests") } - Define test constants at the package level for configuration and test data:
// Test only when the server is changed const testLineToken = "" const testLineMemberID = "" // Test message payload templates const lineMessageTemplateText = `{ "messages": [ { "type":"text", "text":"Hello, world" } ] }` - Structure integration tests using table-driven tests similar to unit tests:
testCases := []struct { Name string Message message.Message Auth message.ChannelAuth ExpectedStatus message.MessageStatus ExpectedStatusCode int ExpectedError bool ExpectedResolveByRetry bool }{ // Test cases... } - Include both success and failure test cases with detailed expectations
- Test edge cases and error conditions with specific error expectations
- Verify detailed response properties beyond just error/success:
assert.Equal(t, c.ExpectedStatus, status) assert.Equal(t, c.ExpectedStatusCode, channelResponse.StatusCode) assert.Equal(t, c.ExpectedResolveByRetry, err.ResolveByRetry()) - Create helper functions to build test data and requests:
func buildTestLineAuth(token string) message.ChannelAuth { return message.ChannelAuth{ Token: token, } } func buildTestLineMessage(recipientID string, messageTemplate string) message.Message { return message.Message{ MessageID: uuid.NewString(), RecipientID: recipientID, ChannelType: message.ChannelTypeLine, // ...other fields } } - Use Docker Compose for integration tests with external dependencies
- Clean up resources after tests
- Use environment variables to configure test behavior
- Consider using testcontainers for ephemeral test dependencies
Mocking
- Use interface mocks for testing
- Generate mocks using
mockgenwith//go:generatedirectives - Place mocks in an
automocksubdirectory - Create structured mock objects for complex services:
type serviceTestMock struct { Repository *automock.MockRepository Factory *automock.MockFactory EventBroker *automock.MockEventBroker // Other dependencies } - Create helper functions to build mock structures:
func buildServiceMock(ctrl *gomock.Controller) serviceTestMock { return serviceTestMock{ Repository: automock.NewMockRepository(ctrl), Factory: automock.NewMockFactory(ctrl), EventBroker: automock.NewMockEventBroker(ctrl), } } - Create helper functions to build services with mocked dependencies:
func buildService(mock serviceTestMock) *Service { param := ServiceParam{ Repository: mock.Repository, Factory: mock.Factory, EventBroker: mock.EventBroker, } return NewService(context.Background(), param) } - Use helper functions to build test data:
func buildTestData(size int, status Status, url string) TestData { // Build and return test data } - Use EXPECT() to set up mock expectations with appropriate matchers
- Use gomock.Any() for parameters that don't need specific matching
- Use AnyTimes() for expectations that may be called multiple times
- Add comments for expectations that are intentionally not set (e.g., when testing feature flags)
//go:generate mockgen -destination automock/message_repository.go -package=automock . MessageRepository
Test Helpers
- Create reusable helper functions for common test operations
- Place test helpers in the same package as the tests they support
- Use descriptive names for helper functions that indicate their purpose
- Create helpers for building test data with sensible defaults:
func buildTestMessageDelivery(messageBatchSize int, status message.MessageStatus, callBackURL string) message.MessageDelivery { // Build and return test data with the specified parameters } - Create helpers for setting up mock expectations for common scenarios
- Use faker libraries for generating random test data:
import "github.com/bxcodec/faker/v3" // Use faker to generate random data recipientID := faker.URL() - Use uuid libraries for generating unique identifiers:
import "github.com/google/uuid" // Generate a unique ID messageID := uuid.NewString() - Create helpers that encapsulate complex assertion logic
- Document helper functions with clear comments explaining their purpose and parameters
- Keep helper functions focused on a single responsibility
- Consider parameterizing helpers to make them more flexible
Performance Considerations
Memory Management
- Avoid unnecessary memory allocations
- Use pointers judiciously - only when you need to share mutable state
- Consider using sync.Pool for frequently allocated objects
- Be aware of escape analysis and stack vs. heap allocations
Efficiency
- Use buffered channels when appropriate
- Preallocate slices when you know the size in advance
- Use string concatenation efficiently (strings.Builder for multiple concatenations)
- Profile your code to identify bottlenecks
Logging and Observability
Structured Logging
- Use structured logging with
zerolog - Include relevant context in log entries
- Use appropriate log levels (debug, info, warn, error)
- Don't log sensitive information
- Create logger helpers that add component information
logger := zerolog.Ctx(ctx).With().
Str("component", "gateway-service").
Str("message_id", message.ID).
Logger()
logger.Info().Msg("Processing message")
Metrics and Tracing
- Instrument code with metrics for important operations
- Use distributed tracing for request flows
- Monitor error rates and latencies
- Use context for propagating trace information
Documentation
Code Comments
- All exported functions, types, and constants should have comments
- Comments should start with the name of the thing being described
- Use complete sentences with proper punctuation
- For interfaces, document the behavior, not the implementation
- Focus on why, not just what
- Keep comments up to date with code changes
// MessageStatus is used to indicate the processing status of a MessageDelivery.
type MessageStatus string
// Message represents the message that will be sent to a channel in the system.
// It is an interface to communicate between channels and the system.
type Message struct {
// ...
}
README and Documentation
- Maintain clear README files for each package
- Document architecture decisions
- Include examples for complex functionality
- Keep documentation close to the code it describes
Common Pitfalls to Avoid
Nil Pointer Dereferences
- Check for nil before dereferencing pointers
- Be careful with interface nil checks
- Initialize all fields in structs
- Use pointer receivers consistently
Race Conditions
- Use proper synchronization (mutex, channels) when sharing data
- Run tests with the race detector (
go test -race) - Avoid global mutable state
- Be careful with closures capturing loop variables
Resource Leaks
- Always close resources (files, connections, etc.)
- Use defer for cleanup operations
- Check for errors when closing resources
- Consider using errgroup for managing multiple resources
file, err := os.Open(filename)
if err != nil {
return err
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
// Log the error
}
}()
Dependency Injection
- Use parameter structs for services with many dependencies
- Initialize all dependencies in constructors
- Avoid global state and singletons
Project Structure
Main Directories
-
cmd/- Contains the main applications -
internal/- Private application and library codedomain/- Core business logic and entitiesorganization/- Organization and channel domain models and business rulesmessage/- Message domain models and business rulescommon/- Shared domain logic
app/- Application servicesservice/- Contains various services like gateway, messageworker, callbackworker
adapter/- External integrationsserver/- External server implementationsrepository/- Data storage implementationseventbroker/- Event broker implementations
router/- Routing logic
-
docs/- Documentation files
Configuration
The application uses a combination of environment variables and command-line flags for configuration, with defaults defined in cmd/app/main.go.
Development Workflow
- Use
make testfor running tests - Use
make mockto update mock objects - Use
make runto start the application locally - Docker Compose is available for setting up dependent services
Key Files
cmd/app/main.go- Application entry point and configurationinternal/app/application.go- Core application structureMakefile- Development commands and workflows