Instruction file imported from chatbotgang/ai-coding-workshop-250712 (
.cursor/rules/go.mdc). Copyright stays with the author.
globs: *.go alwaysApply: false
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