Simple Enough Blog logo
  • Home 
  • Projects 
  • Tags 

  •  Language
    • English
    • Français
  1.   Blogs
  1. Home
  2. Blogs
  3. Interfaces, Functions and Modules in Go: Structuring Your Code for TDD Without Adding Unnecessary Complexity

Interfaces, Functions and Modules in Go: Structuring Your Code for TDD Without Adding Unnecessary Complexity

Posted on December 15, 2025 • 5 min read • 871 words
Golang   Tdd   Architecture   Tests   Modularity   Devops   Thibault  
Golang   Tdd   Architecture   Tests   Modularity   Devops   Thibault  
Share via
Simple Enough Blog
Link copied to clipboard

How do you use interfaces, functions and Go modules in a Test-Driven Development mindset? How do you mock, where should interfaces live, how do you isolate a package that exposes only functions, and how do you avoid unnecessary complexity?

On this page
I. Introduction   II. Go’s Model: Simple, Modular, but Different   III. Go Modules and TDD: Where Should Interfaces Live?   Où mettre l’interface ?   Example of an interface defined in bar   IV. How to Mock a Package That Only Exports Functions?   1. Option A — Function Injection (simple and idiomatic)   Option B — Introduce a struct in foo (for multi-method contracts)   V. Interface vs Function Injection: Comparison Table   VI. Detecting When an Implementation No Longer Satisfies an Interface   VII. Idiomatic TDD Workflow in Go   VIII. Real-World Cases Where Function Injection Is the Best Choice   IX. Impact of Modern Tools (AI, Cursor, etc.)   What’s changing:   X. Conclusion   🔗 Useful Links   Official Go documentation   Tests & TDD in Go  
Interfaces, Functions and Modules in Go: Structuring Your Code for TDD Without Adding Unnecessary Complexity
Photo by Thibaut Deheurles

I. Introduction  

Go is a minimalist language, but this simplicity can sometimes make developers feel like something is “missing” — especially when dealing with more advanced architectures, Test-Driven Development, or the need to mock dependencies. Developers coming from Java, C#, or Python often ask the same questions:

  • Should I turn all my functions into structs + interfaces to test them?
  • How should I organize my packages to keep them modular and testable?
  • Where should interfaces live — in the provider or the consumer?
  • How do I isolate a package that only exports functions?
  • How does Go automatically detect signature changes?

II. Go’s Model: Simple, Modular, but Different  

Unlike Java or C#, Go does not rely on inheritance, classes, or heavy dependency injection frameworks.

In Go:

  • a package is the unit of encapsulation,
  • a public function is often enough,
  • an interface describes minimal behavior,
  • interfaces are usually defined in the consumer,
  • tests guide when abstractions should be introduced — not the reverse.

Go’s philosophy is captured in one line:

“Accept interfaces, return structs.”

In other words: introduce abstraction only when it becomes useful, not before.


III. Go Modules and TDD: Where Should Interfaces Live?  

Let’s imagine two packages:

foo/
  foo.go

and

bar/
bar.go

bar consumes foo.

Où mettre l’interface ?  

In the consumer package (bar), not in foo.

Why ?

  • The consumer knows exactly what it needs.
  • The provider does not need to enforce a rigid contract.
  • Consumers can define minimal interfaces, easier to fake/mock.
  • Coupling remains low.
  • Tests become simpler.

Example of an interface defined in bar  

// package bar

type Fooer interface {
    Do(ctx context.Context, input string) (string, error)
}

type Service struct {
    foo Fooer
}

func NewService(foo Fooer) *Service {
    return &Service{foo: foo}
}

Production :

f := foo.NewClient(...)
svc := bar.NewService(f)

Test :

type fakeFoo struct{}

func (f *fakeFoo) Do(ctx context.Context, in string) (string, error) {
    return "OK", nil
}

svc := bar.NewService(&fakeFoo{})

IV. How to Mock a Package That Only Exports Functions?  

Suppose:

// package foo
func Compute(ctx context.Context, x int) (int, error) {
    ...
}

You want to isolate Compute inside bar.

Go offers two idiomatic models.

1. Option A — Function Injection (simple and idiomatic)  

// package bar

type ComputeFunc func(ctx context.Context, x int) (int, error)

type Service struct {
    compute ComputeFunc
}

func NewService() *Service {
    return &Service{
        compute: foo.Compute,
    }
}

func NewServiceWithCompute(f ComputeFunc) *Service {
    return &Service{
        compute: f,
    }
}

Test :

svc := bar.NewServiceWithCompute(func(ctx context.Context, x int) (int, error) {
    return 42, nil
})
  • Ideal for isolating a single function
  • No interface needed
  • Lightweight and highly readable

Option B — Introduce a struct in foo (for multi-method contracts)  

// package foo
type Computer struct{}

func NewComputer() *Computer { return &Computer{} }

func (c *Computer) Compute(ctx context.Context, x int) (int, error) {
return Compute(ctx, x)
}

And in bar :

type Computer interface {
    Compute(ctx context.Context, x int) (int, error)
}
  • Pro: Good for complex contracts
  • Con: Heavy for a single function

V. Interface vs Function Injection: Comparison Table  

CriteriaInterfaceFunction Injection
When to useComplex behaviorSimple dependency
Number of methodsSeveral1
TestabilityVery goodExcellent
BoilerplateMediumVery low
CouplingLowSlightly higher
Refactoring safetyVery strongMay break silently
Go idiomatic levelVery idiomaticPerfectly idiomatic

Conclusion:

  • Interfaces → best for rich components
  • Functions → best for simple or isolated dependencies

VI. Detecting When an Implementation No Longer Satisfies an Interface  

If bar defines an interface Fooer, you can enforce compile-time compatibility via:

var _ bar.Fooer = (*foo.Client)(nil)

If a method is missing or its signature changes → the compilation fails.

This pattern is standard across all large Go codebases.


VII. Idiomatic TDD Workflow in Go  

Here is a simple and effective workflow:

  1. Start with simple functions
    No premature abstractions.

  2. Let the tests tell you when abstraction is needed

  3. Define interfaces in the consumer

  4. Inject dependencies (struct, interface, or function)

  5. Use minimal fakes in tests

  6. Verify compatibility using:

   var _ Interface = (*Impl)(nil)

Refactor when tests become difficult.

Go encourages clear, simple, and non-over-engineered code.


VIII. Real-World Cases Where Function Injection Is the Best Choice  

Go teams frequently isolate:

  • time.Now → now func() time.Time
  • rand.Int → randFunc func() int
  • http.Client.Do → Doer func(*http.Request) (*http.Response, error)
  • os.ReadFile → readFile func(string) ([]byte, error)

Why?

Because it’s simple, flexible, testable, fast — and perfectly Go.


IX. Impact of Modern Tools (AI, Cursor, etc.)  

You asked whether classic mock frameworks are becoming obsolete.

  • Yes → AI-generated fakes reduce the need for heavy mocking libraries.
  • No → Architecture still matters; AI does not replace clean design.

What’s changing:  

  • fewer complex mocks
  • more minimalistic interfaces
  • more function injection
  • more AI-assisted refactoring

X. Conclusion  

Go encourages simplicity, and this mindset fits perfectly with TDD:

  • Public functions are perfectly valid.
  • Interfaces should live in consumers, not providers.
  • Function injection is powerful and often underrated.
  • TDD works extremely well in Go — as long as you avoid unnecessary abstractions.
  • Go’s compiler lets you detect signature inconsistencies automatically.

By applying these principles, your Go code will remain:

  • testable
  • clean
  • extensible
  • idiomatic
  • and readable by any experienced Go developer

🔗 Useful Links  

Official Go documentation  

  • Effective Go
  • The Go Playground

Tests & TDD in Go  

  • Testing package
 Using Constants in TDD with Go
Consumer-Reported Dependency Health 
  • I. Introduction  
  • II. Go’s Model: Simple, Modular, but Different  
  • III. Go Modules and TDD: Where Should Interfaces Live?  
  • Où mettre l’interface ?  
  • IV. How to Mock a Package That Only Exports Functions?  
  • V. Interface vs Function Injection: Comparison Table  
  • VI. Detecting When an Implementation No Longer Satisfies an Interface  
  • VII. Idiomatic TDD Workflow in Go  
  • VIII. Real-World Cases Where Function Injection Is the Best Choice  
  • IX. Impact of Modern Tools (AI, Cursor, etc.)  
  • X. Conclusion  
  • 🔗 Useful Links  
Follow us

We work with you!

   
Copyright © 2026 Simple Enough Blog All rights reserved. | Powered by Hinode.
Simple Enough Blog
Code copied to clipboard