Emptying the Backpack With the Go Language

Key Points
  • 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
Opinion Alert
Before you close this article thinking it's another extremist manifesto, let me clarify: I don't advocate banning concepts and technologies. For me, technologies are tools, and each context has its needs. What I propose in this article is something different: I want to show how the Go language, by design, offers pragmatic alternatives for those seeking simplicity without sacrificing quality, scalability, and developer experience. And yes, there are other languages that also meet this requirement, and perhaps in the future I'll write a continuation of this article. Who knows...

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 toolsgo test, go vet, go fmt, go mod and now govulncheck. 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".

Insight

"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.

Advantages
  • 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
Trade-offs
  • 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
Advantages
  • 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
Trade-offs
  • 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)
}
Advantages
  • 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
Trade-offs
  • 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))
}
Advantages
  • 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
Trade-offs
  • 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
Advantages
  • 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
Trade-offs
  • 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()
}
Advantages
  • 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
Trade-offs
  • 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)
    }
}
Advantages
  • 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
Trade-offs
  • 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
    }
}
Advantages
  • 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
Trade-offs
  • 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)
}
Advantages
  • 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
Trade-offs
  • 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
Advantages
  • 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
Trade-offs
  • 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
}
Advantages
  • 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
Trade-offs
  • 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.

Insight

"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

Fundamental Talks

Tools Mentioned

Community