Emptying the Backpack With the Go Language
- Go promotes radical simplicity by eliminating unnecessary abstractions, resulting in more readable and maintainable code that grows linearly with complexity
- Composition over inheritance and explicit wiring eliminate framework magic, making data flow transparent and debugging trivial
- Functions as first-class values naturally replace many complex design patterns like Strategy, Decorator, and Command
- Integration tests with real containers offer more confidence than pyramids of isolated unit tests with heavy mocks
- Each simplification has clear trade-offs: fewer abstractions mean more manual code, but also greater clarity and control over the system
This week I read a post by Sibelius Seraphini about his determination to ban complexity in software development at Woovi. This post highlights a growing sentiment in the development community: fatigue with unnecessary complexity. Sibelius listed concepts that were considered unquestionable "best practices" a few years ago, but often create more problems than they solve, and undoubtedly add significant complexity:
Sibelius Seraphini
Co-Founder at Woovi
Things I've banned here at Woovi:
• OOP
• Dependency Injection
• Mutability
• Gang of Four
• CMDB
• Clean Code
• Clean Architecture
• Service, Controllers, Repository, ValueObject
• Getters, Setters
• Decorators
• Unit Testing
This article isn't about switching from Java to Elixir or embracing pure functional programming. It's about how Go — an imperative and "boring" language — manages to deliver quality software with a fraction of the ceremony we accept as normal.
Why Go favors minimalism
Go was born to solve a cultural problem at Google: compile fast, be easy to read in code review, and run in production without pain. To achieve these goals, the language delivers:
- Static typing with modest inference — you get type safety without having to memorize complex generics.
- Powerful standard library — HTTP, JSON, context, crypto, concurrency; half of what in other languages is "framework" Go already brings out of the box.
- Integrated build and test tools —
go test
,go vet
,go fmt
,go mod
and nowgovulncheck
. The ecosystem encourages simple pipelines. - Lightweight concurrency runtime — goroutines cost bytes; channels compose lock-free communication patterns.
When the language delivers the essentials, the risk of looking for "savior frameworks" decreases. Fewer layers, fewer mocks, fewer meetings about "which pattern to use".
"Fewer layers, fewer mocks, fewer meetings about 'which pattern to use'."
You don't need…
PROGRAMMING PARADIGMS
1. OOP — Composition > Inheritance
Object orientation was born to model complex worlds with hierarchies and polymorphism. In Go, the syntax already reinforces another mindset: composition > inheritance.
// Example: billing domain using immutability
type Invoice struct {
ID int
Amount int64 // always in cents to avoid float
Paid bool
}
// Why pass by value and not *Invoice?
// 1. Small structs (<256 bytes) are copied efficiently
// 2. Immutability = zero race conditions in goroutines
// 3. Trivial rollback: just don't use the return
func Pay(inv Invoice) (Invoice, error) {
// Original invoice remains intact - safe for concurrency
if inv.Paid {
return Invoice{}, fmt.Errorf("already paid")
}
// Modifies the local copy, not the original
inv.Paid = true
return inv, nil // returns new version
}
There is no implicit inheritance. If you need polymorphism, do it via small and concise interfaces that describe behavior.
- Significant code reduction: Consolidation of multiple hierarchical classes into simpler, more direct structures
- More efficient refactoring: Localized changes without propagation through complex hierarchies
- Reduced learning curve: More linear structure facilitates domain understanding
- Elimination of classic inheritance problems: No diamond problem or virtual method ambiguities
- Necessary adaptation: Developers with OOP experience need to adjust their approach
- Manual management of common code: Absence of inheritance requires different strategies for reuse
- Limited tool support: Fewer automatic resources for refactoring and structure visualization
- Care with embedding: Field promotion can generate conflicts that require attention
2. Gang of Four Patterns — Functions as first-class citizens
The 23 GoF patterns were born in 1994 to solve limitations of C++ and Smalltalk. In Go, many of them are unnecessary because the language already offers very powerful (though simple) primitive structures.
For example:
// Strategy Pattern: function as parameter replaces class hierarchy
func ProcessPayment(amount int64, strategy func(int64) error) error {
// In Java: PaymentStrategy interface + N implementations
// In Go: pass the function directly
return strategy(amount)
}
// Usage: ProcessPayment(100, processWithStripe)
// ProcessPayment(100, processWithPayPal)
// Decorator Pattern: higher-order function instead of inheritance
func WithRetry(fn func() error) func() error {
return func() error {
// Try 3x with exponential backoff
for i := 0; i < 3; i++ {
if err := fn(); err == nil {
return nil // success, exit loop
}
// Wait 1s, 2s, 3s between attempts
time.Sleep(time.Second * time.Duration(i+1))
}
return fn() // last attempt
}
}
// Observer Pattern: channels replace callbacks/listeners
type EventBus struct {
// map[topic] → list of interested channels
subscribers map[string][]chan Event
mu sync.RWMutex // protection for concurrent map
}
func (e *EventBus) Subscribe(topic string) <-chan Event {
ch := make(chan Event, 10) // buffer prevents publisher blocking
e.mu.Lock()
e.subscribers[topic] = append(e.subscribers[topic], ch)
e.mu.Unlock()
return ch // returns read-only channel to subscriber
}
GoF Pattern | Need in Go | Idiomatic Alternative |
---|---|---|
Singleton | Rare | sync.Once + global var |
Factory | Unnecessary | Constructor functions |
Builder | Occasional | Functional options |
Adapter | Unnecessary | Implicit interfaces |
Bridge | Never | Composition |
Composite | Rare | Recursive structs |
Proxy | Unnecessary | Interface + wrapper |
Command | Unnecessary | Functions + closures |
Iterator | Built-in | range loops |
Mediator | Rare | Channels |
Memento | Simple | Copy struct |
State | Occasional | Type switches |
Template | Unnecessary | Functions + embedding |
Visitor | Complex | Type assertions |
- More concise implementation: Complex patterns replaced by functions as values
- Easier localization: Simple text search quickly identifies all uses
- Optimized performance: Elimination of indirections and possibility of compiler optimizations
- Simplified tests: Direct function replacement without need for complex frameworks
- Reinterpretation of established patterns: Classic design pattern concepts require new approach
- Navigation tool limitations: IDE features less effective for tracking functional implementations
- Closure debugging: Breakpoints in anonymous functions can be less intuitive
- Absence of metaprogramming: Cross-cutting aspects implemented manually
3. Decorators — Higher-order functions
Decorators in languages like Python/Java add behavior via annotations. Go uses composition and higher-order functions:
// Classic decorator pattern in Go
type Handler func(http.ResponseWriter, *http.Request)
// Authentication decorator
func WithAuth(h Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", 401)
return
}
h(w, r)
}
}
// Logging decorator
func WithLogging(h Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
h(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
}
}
// Rate limiting decorator using higher-order function
func WithRateLimit(limit int) func(Handler) Handler {
// limiter is created ONCE and "captured" by the closure
// In Java/C# it would be an instance variable in a class
limiter := rate.NewLimiter(rate.Limit(limit), limit)
// Returns function that "remembers" the limiter via closure
return func(h Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
// limiter persists between requests - shared state
// without needing classes or singletons
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", 429)
return
}
h(w, r) // calls next handler in chain
}
}
}
// Explicit composition: each layer is visible and testable
func main() {
// Read from inside out: payment → rate limit → auth → logging
// In Java these would be 4 classes with inheritance or dynamic proxies
handler := WithLogging(
WithAuth(
WithRateLimit(100)( // 100 req/s per instance
handlePayment, // original handler without decoration
),
),
)
// Stack trace shows exactly the execution order
http.HandleFunc("/payment", handler)
}
- Clearer stack traces: Decorator chain visible and traceable
- Modular tests: Each decorator can be tested independently
- No reflection overhead: Composition resolved at compile time
- Explicit flow: Clear execution order directly in code
- Complexity with multiple layers: Manual composition can become less readable with many decorators
- Absence of annotations: Cross-cutting behaviors must be explicitly coded
- Manual ordering: Developer responsible for defining correct sequence
- Potential duplication: Similar patterns may repeat between handlers
ARCHITECTURE AND DESIGN
4. Dependency Injection Containers — Explicit wiring
In many ecosystems, DI solves cyclic coupling and expensive object fabrication. Go simplifies by making dependencies explicit in the constructor or function:
// Manual injection: dependencies are explicit parameters
func makePayHandler(store InvoiceStore) http.HandlerFunc {
// Closure captures store - no need for struct field
return func(w http.ResponseWriter, r *http.Request) {
// store available via closure, not via this.store
invoice, _ := store.FindByID(r.Context(), "123")
// … process payment …
}
}
func main() {
// Manual wiring: you see each dependency being created
db, _ := sql.Open("postgres", os.Getenv("PG_CONN"))
store := pgStore{db: db}
// Explicit composition - no magic @Autowired
http.Handle("/pay", makePayHandler(store))
// Dependency tree visible in main()
log.Fatal(http.ListenAndServe(":8080", nil))
}
- Dependency traceability: Changes in dependencies clearly visible in code reviews
- Faster initialization: Reduced startup time without scanning and proxy creation
- Direct debugging: Object creation flow easily traceable
- Simplified search: Dependency location through simple text search
- Manual wiring scalability: Complexity increases with the number of components
- Extensive initial configuration: Large projects can have substantial initial setup
- Manual lifecycle management: Responsibility for managing initialization and finalization order
- Recognized limitations: Tools like Wire were created to help in very large projects
5. Clean Architecture — Pragmatic organization
Clean Architecture promises framework independence and total testability. In practice, many projects end up with an explosion of abstractions that obscure business logic:
// Typical "Clean" approach - 7 files for a simple operation
// user/domain/entities/user.go
type User struct { ID, Name string }
// user/domain/repositories/user_repository.go
type UserRepository interface {
FindByID(context.Context, string) (*User, error)
}
// user/application/usecases/get_user.go
type GetUserUseCase struct {
repo UserRepository
}
// user/infrastructure/persistence/postgres_user_repository.go
type PostgresUserRepository struct { db *sql.DB }
// user/interfaces/http/user_controller.go
type UserController struct {
useCase *GetUserUseCase
}
// user/interfaces/http/dto/user_dto.go
type UserDTO struct { ID, Name string }
// main.go - 50 lines of wiring
Compare with the pragmatic Go approach:
// users/users.go - domain and interface in the same place
package users
type User struct {
ID string
Name string
}
// Interface declared where it's used, not where it's implemented
// Go philosophy: "accept interfaces, return structs"
type Store interface {
FindByID(ctx context.Context, id string) (*User, error)
}
// GetByID accepts any type that implements Store
// No need to declare "implements Store" - implicit duck typing
func GetByID(ctx context.Context, store Store, id string) (*User, error) {
// Business rule stays in domain, not in repository
if id == "" {
return nil, errors.New("id required")
}
// Store can be postgres, mock, redis... doesn't matter
return store.FindByID(ctx, id)
}
// users/postgres.go - concrete implementation
type postgresStore struct { db *sql.DB }
// Returns Store (interface), not *postgresStore (concrete)
// This decouples consumers from specific implementation
func NewPostgresStore(db *sql.DB) Store {
return &postgresStore{db: db}
}
// FindByID exists? postgresStore implements Store automatically!
// No "implements", no hierarchy, no coupling
func (p *postgresStore) FindByID(ctx context.Context, id string) (*User, error) {
// implementation...
}
// cmd/api/main.go - explicit wiring without magic
db := connectDB()
userStore := users.NewPostgresStore(db) // Store interface
http.HandleFunc("/users/", makeUserHandler(userStore))
Resulting folder structure:
myapp/
├── users/ # everything about users
│ ├── users.go # types and rules
│ └── postgres.go # Store implementation
├── billing/ # everything about billing
├── shipping/ # everything about shipping
└── cmd/
└── api/ # entry point
- Faster learning: Domain-based structure facilitates understanding for new members
- Simplified navigation: Organization by feature instead of technical layer
- Tests close to code: Tests located together with implementation
- Fewer files: Leaner structure compared to multi-layer implementations
- Architectural paradigm shift: There may be questions about the absence of traditional layers
- Need for discipline: Prevention of circular imports depends on team conventions
- More coupled persistence switching: Database changes may require more modifications
- Divergence from established patterns: Traditional architectures need to be reinterpreted
6. Clean Code — Clarity > ceremony
The Clean Code book popularized practices like "functions should do one thing only" and "names should be expressive". While valid, many teams have turned these guidelines into almost religious dogma:
// "Clean Code" taken literally
type UserCreationService struct {
validator UserValidator
repository UserRepository
notifier NotificationService
logger Logger
}
func (s *UserCreationService) CreateUser(dto UserCreationDTO) (*User, error) {
validatedDTO, err := s.validator.ValidateUserCreationDTO(dto)
if err != nil {
return nil, s.wrapErrorWithContext(err, "validation failed")
}
user := s.mapDTOToUser(validatedDTO)
savedUser, err := s.repository.SaveUser(user)
if err != nil {
return nil, s.wrapErrorWithContext(err, "save failed")
}
s.notifier.NotifyUserCreated(savedUser)
s.logger.LogUserCreation(savedUser)
return savedUser, nil
}
// 15 helper methods of 3 lines each...
Compare with idiomatic Go code:
// Pragmatic Go: clarity instead of ceremony
func CreateUser(db *sql.DB, name, email string) (int64, error) {
// Inline validation when simple
if name == "" || email == "" {
return 0, errors.New("name and email required")
}
// Direct operation
result, err := db.Exec(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
name, email,
)
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return result.LastInsertId()
}
- More concise stack traces: Fewer levels of indirection to trace
- Simplified debugging: Concentrated logic facilitates problem identification
- More efficient code reviews: Fewer files and indirections to analyze
- Reduced context switching: Related logic kept close together
- Relaxed single responsibility principle: Functions may have multiple related responsibilities
- Potentially more comprehensive tests: Larger functions may require more complex test scenarios
- Different coverage metrics: Less granularity in test metrics
- Less granular refactoring: Changes may affect larger blocks of code
7. Service/Controller/Repository — Direct handlers
The MVC pattern and its variations create artificial layers that often just move data from one side to another:
// Traditional pattern: 4 files for a CRUD
// controller/user_controller.go
type UserController struct {
service *UserService
}
func (c *UserController) GetUser(id string) (*UserDTO, error) {
user, err := c.service.GetUser(id)
if err != nil {
return nil, err
}
return mapToDTO(user), nil
}
// service/user_service.go
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(id string) (*User, error) {
// Literally just passes the call
return s.repo.FindByID(id)
}
// repository/user_repository.go
type UserRepository interface {
FindByID(id string) (*User, error)
}
// dto/user_dto.go
type UserDTO struct {
ID string `json:"id"`
Name string `json:"name"`
}
Go approach: handlers that do the work:
// users.go - everything together and mixed (on purpose!)
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
}
func MakeUserHandler(store UserStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user, err := store.GetUser(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
}
- Substantial reduction of indirection: Direct flow from request to storage
- Simplified traceability: Simple text search locates the entire flow
- More direct tests: Fewer layers to configure and mock
- Less boilerplate code: More concise implementation without intermediate layers
- Adaptation for developers from other frameworks: Absence of annotations and familiar layers
- Manual implementation of aspects: Cross-cutting features must be explicitly added
- Business logic close to transport: Less separation between layers
- Reuse between protocols: May require refactoring to support multiple API types
DEVELOPMENT PRACTICES
8. Shared Mutability — Channels and immutability
In traditional languages, mutable objects shared between threads are an inexhaustible source of bugs: race conditions, deadlocks, inconsistent state. Go embraces the philosophy "Don't communicate by sharing memory; share memory by communicating".
Copying small structs is cheap — the compiler optimizes aggressively. For larger data, use channels to coordinate changes:
// Immutable state - each operation returns new copy
type Account struct {
Balance int64
Locked bool
}
func Withdraw(acc Account, amount int64) (Account, error) {
if acc.Locked {
return Account{}, errors.New("account locked")
}
if acc.Balance < amount {
return Account{}, errors.New("insufficient balance")
}
acc.Balance -= amount
return acc, nil
}
// Coordination via channel - "share memory by communicating"
type Bank struct {
// Single channel = operation queue = zero race conditions
accounts chan accountOp
}
type accountOp struct {
id string
amount int64
result chan error // asynchronous response to client
}
// Single goroutine manages all state - actor pattern
func (b *Bank) processOps() {
// Private state of goroutine - no one else accesses
state := make(map[string]Account)
// Infinite loop processing one operation at a time
for op := range b.accounts {
// Get current account (zero value if doesn't exist)
acc := state[op.id]
// Apply immutable operation
newAcc, err := Withdraw(acc, op.amount)
if err == nil {
state[op.id] = newAcc // update local state
}
// Respond to client via channel
op.result <- err
}
}
- Drastic reduction of concurrency bugs: Race conditions minimized through isolation
- Simplified rollback: Errors don't affect previous state
- More predictable behavior: Deterministic execution flow
- Efficient scalability: Lightweight concurrency model allows many goroutines
- Memory impact: Frequent copies of large structures increase allocations
- Increased garbage collector activity: Pattern of many small allocations
- Complexity with multiple channels: Coordination can become difficult to manage
- Latency considerations: Copies may impact critical performance paths
9. Unit Tests vs Integration Tests — Pragmatic balance
There's value in both approaches. Unit tests are excellent for complex business logic:
// Valuable unit test - tests pure business logic
func TestCalculateDiscount(t *testing.T) {
cases := []struct {
amount int64
userType string
expected int64
}{
{1000, "regular", 900}, // 10% discount
{1000, "premium", 800}, // 20% discount
{500, "regular", 500}, // no discount below minimum
}
for _, tc := range cases {
result := CalculateDiscount(tc.amount, tc.userType)
assert.Equal(t, tc.expected, result)
}
}
// Excessive mock - avoid for trivial code
type MockUserRepo struct {
mock.Mock
}
// ... 20 lines of mock to test 3 lines of code
For complete flows, prefer integration tests with testcontainers:
// E2E test with real database - maximum confidence, zero mocks
func TestUserAPI(t *testing.T) {
ctx := context.Background()
// Postgres container starts in ~500ms (first time ~2s with download)
// Alpine = minimal image, fast startup
postgres, _ := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:15-alpine"),
postgres.WithDatabase("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
// Waits for specific log that indicates "ready to use"
// Postgres emits this msg 2x during startup
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(5 * time.Second),
),
)
defer postgres.Terminate(ctx) // automatic cleanup
// Container provides dynamic connection string (random port)
connStr, _ := postgres.ConnectionString(ctx, "sslmode=disable")
db, _ := sql.Open("postgres", connStr)
runMigrations(db) // clean database for each test
// Complete stack: HTTP → Handler → Store → Real Postgres
store := users.NewPostgresStore(db)
handler := makeUserHandler(store)
srv := httptest.NewServer(handler)
// Real integration test - exactly like production
resp, _ := http.Post(srv.URL+"/users", "application/json",
strings.NewReader(`{"name":"Alice"}`))
var created User
json.NewDecoder(resp.Body).Decode(&created)
// Fetch to validate real persistence
resp, _ = http.Get(srv.URL + "/users/" + created.ID)
var fetched User
json.NewDecoder(resp.Body).Decode(&fetched)
// No mocks = no drift between test and production
assert.Equal(t, "Alice", fetched.Name)
assert.NotEmpty(t, fetched.ID)
}
// Contract test with external service that doesn't have container
func TestPaymentGateway(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test") // skip in fast CI
}
// WireMock when there's no official service image
// Ex: Stripe, PayPal, Twilio - proprietary APIs
wiremock, _ := wiremock.RunContainer(ctx)
defer wiremock.Terminate(ctx)
// Define expected contract - based on API doc
client := wiremock.GetClient()
client.StubFor(wiremock.Post("/charge").
WithHeader("Authorization", wiremock.EqualTo("Bearer token")).
WillReturnJSON(map[string]interface{}{
"id": "ch_123",
"status": "succeeded",
}, 200))
// Test integration with real HTTP, not in-memory mocks
gateway := NewPaymentGateway(wiremock.GetURI())
result, err := gateway.Charge(ctx, 5000, "token")
// Validate behavior, not implementation
assert.NoError(t, err)
assert.Equal(t, "succeeded", result.Status)
// Pro: tests serialization, headers, timeouts
// Con: may diverge from real API (use Pact to validate)
}
- Significant reduction of production bugs: Tests with real infrastructure increase reliability
- Greater confidence in changes: Integration tests validate real system behavior
- Complete stack validation: All aspects of communication are tested
- Efficient resource usage: Containers can be shared between multiple tests
- Increased CI time and resources: Integration tests take longer and require more resources
- Infrastructure complexity: Container configuration and additional permissions needed
- Possibility of flaky tests: External factors can occasionally affect execution
- Learning curve: Team needs to master containerization tools
10. Change Management — Modern CI/CD
Change management is the traditional bureaucratic process of approving production changes. Go has characteristics that facilitate continuous integration adoption without relying on heavy manual processes:
- Rigorous compiler — catches errors before runtime
- Cheap integration tests — as we saw with testcontainers
- Single binary — deployment is copying a file
- Backward compatibility — Go 1 promise ensures stability
// Simple and reliable CI/CD
func TestMain(m *testing.M) {
// Integration tests running in CI
if os.Getenv("CI") == "true" {
setupTestContainers()
defer teardownTestContainers()
}
code := m.Run()
os.Exit(code)
}
// GitHub Actions
// .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
- run: go test -race ./...
- run: go build -o app
- run: ./deploy.sh # reliable deploy
- Increased deployment frequency: More frequent and incremental deliveries
- Reduced recovery time: Simplified rollback through binary swapping
- Incident reduction: Smaller changes present lower risk
- Faster feedback: Code reaches production more quickly
- Regulatory restrictions: Some sectors still require formal approval processes
- Necessary organizational change: Transition from traditional to agile processes
- Sophisticated infrastructure required: Need for advanced observability and deployment tools
- Greater team responsibility: Direct ownership over production changes
11. Getters/Setters — Open structs
Java popularized the idea of encapsulating all fields with getters/setters. In Go, structs are open by design:
// Java-in-Go approach (don't do this)
type User struct {
id string
name string
age int
}
func (u *User) GetID() string { return u.id }
func (u *User) SetID(id string) { u.id = id }
func (u *User) GetName() string { return u.name }
func (u *User) SetName(name string) { u.name = name }
func (u *User) GetAge() int { return u.age }
func (u *User) SetAge(age int) { u.age = age }
// Idiomatic Go
type User struct {
ID string
Name string
Age int
}
// Validation when necessary, not by default
func NewUser(name string, age int) (*User, error) {
if age < 0 || age > 150 {
return nil, errors.New("invalid age")
}
return &User{
ID: generateID(),
Name: name,
Age: age,
}, nil
}
// Method only when it adds behavior
func (u *User) CanDrink() bool {
return u.Age >= 18
}
- Substantial reduction of boilerplate: Direct field access eliminates unnecessary methods
- Simplified serialization: JSON annotations directly on fields
- Natural composition: Struct embedding without need for wrappers
- Optimized performance: Direct access more efficient than method calls
- More impactful API changes: Changes to public fields affect all consumers
- Non-automatic validation: Validation responsibility lies with consumer code
- Invariants require discipline: Consistency maintenance depends on conventions
- Absence of automatic generators: No annotations for automatic code generation
Practical strategies for E2E tests
When an official Docker image exists
Postgres, Redis, Kafka, NATS, LocalStack already offer ready images. Use the dedicated module in testcontainers-go
for a lean DSL:
redisC, _ := redis.RunContainer(ctx, tc.WithImage("redis:7-alpine"))
url := redisC.GetHostPort("6379/tcp")
When there's no image — WireMock
SaaS services rarely publish binaries. Start a WireMock and reproduce only the necessary routes:
wm := wiremock.New(ctx)
wm.StubFor(wiremock.Post("/charge").WillReturn("{\"paid\":true}", "application/json", 200))
Ultra-light stub with httptest
For simple scenarios, a local server replaces expensive dependencies:
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"paid":true}`)
}))
Contracts to avoid drift
Stubs can detach from the real API. Use Pact (consumer driven) or Schemathesis (schema driven) in a nightly job that hits the sandbox and reports breakage.
Honest trade-offs
Decision | Benefit | Cost / Risk |
---|---|---|
Explicit wiring (no DI container) | Visible dependency graph; simple debugging | Useful config can grow tiresome in systems with hundreds of services |
Immutability via copy | Concurrency proof against races; trivial rollback | Giant structs (>256 KB) increase GC and RAM consumption |
E2E with Testcontainers | Practically production in miniature; high confidence | Test is slower (~1-3 s), CI needs Docker-in-Docker or privileged runner |
HTTP Stubs | Ultra-fast CI, no API costs | Contract drift generates false positives; needs monitoring |
Avoid "Clean" layers | Fewer files, short reading paths | Lack standard "hooks" in large teams; onboarding may need guide |
Read as: there is no silver bullet. Each choice frees up time for one part of the problem and adds risk to another.
When these patterns DO make sense
It would be dishonest not to recognize scenarios where these abstractions add real value:
Clean Architecture:
- Multiple teams work on the same system (clear boundaries reduce conflicts)
- Regulatory compliance requires change traceability (layers facilitate auditing)
- Gradual migration from legacy systems (abstractions allow incremental replacement)
DI Containers:
- System has 50+ interconnected dependencies
- Need for feature toggles in production
- Teams want consistency between multiple microservices
Unit Tests:
- Complex algorithms (parsers, tax calculators, rule engines)
- Public libraries (ensure API contracts)
- Critical logic with multiple edge cases
The key is contextual fitness: evaluate the cost-benefit for YOUR specific problem.
Conclusion
Simplicity isn't doing less; it's doing what's necessary with mastery. Go encourages this mindset: you write less, review faster, compile in seconds, deliver features that pass the canary. The price is giving up the comfort zone provided by frameworks that do everything. In return, the team understands even the byte that crosses the wire.
"Simplicity isn't doing less; it's doing what's necessary with mastery."
That said, I also can't help but comment that I don't totally agree with the more radical model that Sibelius proposed at Woovi. There are contexts where Clean Architecture makes sense (systems with dozens of teams), where DI containers save time (gigantic enterprise applications), where isolated unit tests are valuable (public libraries).
The point isn't to create a new doctrine that replaces the previous one. It's to question dogmas, understand trade-offs, and choose consciously. Go gives us the opportunity to start from scratch and ask: "do we really need this?". Most of the time, the answer is no.
Additional Reading
Official Resources
- Effective Go — The canonical guide to Go style and idioms
- Go Code Review Comments — Checklist used by the Go team
- Go Proverbs — Condensed wisdom from the community
Fundamental Talks
- Simplicity is Complicated - Rob Pike — Why Go seems simple but isn't simplistic
- Go at Google: Language Design in the Service of Software Engineering — Design decisions explained
Tools Mentioned
- testcontainers-go — Integration tests with containers
- WireMock — HTTP API mocking for tests
Community
- GopherCon Talks — Archive of talks from the main conference
- r/golang — Daily community discussions