Thank you for considering contributing to the Signals library! This document outlines the development workflow and guidelines.
We welcome contributions in many forms:
- Bug reports - Help us identify and fix issues
- Feature requests - Suggest new functionality
- Documentation - Improve guides, examples, and API docs
- Code contributions - Submit bug fixes or new features
- Testing - Add test cases, improve coverage
- Performance - Optimize hot paths, reduce allocations
- Examples - Create usage examples and tutorials
- Go 1.25 or later
- golangci-lint (install with
make install-lint) - Git
- Basic understanding of reactive programming (helpful but not required)
# Clone repository
git clone https://github.com/coregx/signals.git
cd signals
# Verify Go version
go version # Should be 1.25 or later
# Install development tools
make install-lint
# Run tests to verify setup
make test-
Create an issue (for non-trivial changes)
- Describe the problem or feature
- Discuss approach with maintainers
- Get feedback before coding
-
Fork and branch
# Fork repository on GitHub # Clone your fork git clone https://github.com/YOUR_USERNAME/signals.git cd signals # Add upstream remote git remote add upstream https://github.com/coregx/signals.git # Create feature branch git checkout -b feature/my-feature
-
Make changes
# Write code following style guidelines # Add tests for new functionality # Update documentation
-
Test thoroughly
# Run all checks make pre-commit # Or run individual checks: make test # Unit tests make test-race # Race detector make benchmark # Performance tests make lint # Code quality make fmt # Format code
-
Commit changes
# Stage changes git add . # Commit with conventional message git commit -m "feat: add new feature X"
-
Push and create PR
# Push to your fork git push origin feature/my-feature # Create pull request on GitHub # Fill out PR template with details
Follow Conventional Commits specification:
<type>(<scope>): <description>
[optional body]
[optional footer]
- feat: New feature (e.g.,
feat: add batch update support) - fix: Bug fix (e.g.,
fix: resolve race condition in Effect) - docs: Documentation changes (e.g.,
docs: update README examples) - style: Code style changes (formatting, etc.)
- refactor: Code refactoring (no behavior change)
- test: Adding or updating tests
- chore: Maintenance tasks (build, dependencies, etc.)
- perf: Performance improvements
feat: add support for custom equality comparers
fix: correct race condition in computed value updates
docs: add examples for effect cleanup
refactor: simplify signal subscription logic
test: add benchmark for concurrent signal updates
perf: optimize computed value cachingRun the pre-commit checks to ensure code quality:
make pre-commitThis runs:
- Code formatting (
go fmt) - Linting (
golangci-lint) - Unit tests
- Race detector
Your PR must meet these requirements:
- Code is formatted (
make fmtorgo fmt ./...) - Linter passes with zero issues (
make lint) - All tests pass (
make test) - Race detector clean (
make test-race) - New code has tests (target: 70%+ coverage for new code)
- Documentation updated (if applicable)
- Benchmarks added for performance-critical code
- Commit messages follow conventions
- No breaking changes without discussion
- No sensitive data committed
- Minimum: 70% overall coverage
- Target: 90%+ for business logic
- Critical paths: 100% coverage for signal core, computed, effect
Check coverage with:
make test-coverage
# Opens coverage.html in browserFollow Go best practices and idioms:
- SOLID, DRY, KISS, YAGNI
- Clarity over cleverness
- Error handling is mandatory
- Thread-safety by default
- Zero allocations in hot paths
- Public types/functions:
PascalCase(e.g.,NewSignal,Get) - Private types/functions:
camelCase(e.g.,notifySubscribers) - Interfaces: Noun or adjective (e.g.,
Readable,Signal) - Test functions:
Test*(e.g.,TestSignal_BasicOperations) - Benchmark functions:
Benchmark_*(e.g.,Benchmark_Signal_Get)
- Implementation:
signal.go,computed.go,effect.go - Tests:
*_test.go(same package) - Benchmarks:
*_bench_test.go - Types:
types.go(shared types) - Options:
options.go(functional options) - Examples:
cmd/example/
// ✅ GOOD - Always check errors
if err := something(); err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// ❌ BAD - Never ignore errors
something()
// ✅ GOOD - Validate inputs
func NewSignal[T any](value T, opts ...Option[T]) *Signal[T] {
if value == nil {
panic("value cannot be nil")
}
// ...
}All public APIs must be thread-safe:
// ✅ GOOD - Protected access
type Signal[T any] struct {
mu sync.RWMutex
value T
}
func (s *Signal[T]) Get() T {
s.mu.RLock()
defer s.mu.RUnlock()
return s.value
}
// ❌ BAD - Unprotected access
func (s *Signal[T]) Get() T {
return s.value // Race condition!
}Hot paths must have zero allocations:
// ✅ GOOD - Zero allocations
func (s *Signal[T]) Get() T {
s.mu.RLock()
defer s.mu.RUnlock()
return s.value
}
// ❌ BAD - Allocates on every call
func (s *Signal[T]) Get() T {
return s.getValue() // Function call may allocate
}Verify with benchmarks:
go test -bench=Benchmark_Signal_Get -benchmem
# Should show: 0 B/op 0 allocs/opUse table-driven tests for multiple cases:
func TestSignal_Update(t *testing.T) {
tests := []struct {
name string
initial int
updateFn func(int) int
expected int
}{
{"increment", 5, func(v int) int { return v + 1 }, 6},
{"double", 10, func(v int) int { return v * 2 }, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewSignal(tt.initial)
s.Update(tt.updateFn)
assert.Equal(t, tt.expected, s.Get())
})
}
}- Happy path - Normal usage
- Edge cases - Empty values, zero values, nil
- Error cases - Invalid inputs, panics
- Concurrency - Race conditions, concurrent access
- Performance - Benchmarks for hot paths
Add benchmarks for performance-critical code:
func Benchmark_Signal_Get(b *testing.B) {
s := NewSignal(42)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = s.Get()
}
}- Public APIs: Must have godoc comments
- Complex logic: Explain why, not what
- TODOs: Use
// TODO(username): description
// NewSignal creates a new reactive signal with the given initial value.
// Signals are thread-safe and can be read/written from multiple goroutines.
//
// Example:
//
// count := NewSignal(0)
// count.Set(5)
// fmt.Println(count.Get()) // Output: 5
func NewSignal[T any](value T, opts ...Option[T]) *Signal[T] {
// Implementation
}Provide runnable examples:
func ExampleSignal() {
count := NewSignal(0)
count.Set(5)
fmt.Println(count.Get())
// Output: 5
}- Avoid interface boxing in hot paths
- Reuse buffers with sync.Pool if needed
- Minimize allocations in Get/Set operations
- Profile before optimizing with pprof
Always benchmark performance claims:
# Run benchmarks
make benchmark
# Compare with baseline
go test -bench=. -benchmem -count=5 > new.txt
# (after changes)
benchstat old.txt new.txt- Documentation: Check docs/ and README.md
- Issues: Search existing issues first
- Discussions: Ask questions in GitHub Discussions
- Architecture: Review Architecture Overview
By contributing, you agree that your contributions will be licensed under the MIT License.
Thank you for contributing to Signals!
Your contributions help make reactive programming in Go better for everyone.