Interfaces, Functions and Modules in Go: Structuring Your Code for TDD Without Adding Unnecessary Complexity
Posted on December 15, 2025 • 5 min read • 871 wordsHow 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?

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:
Unlike Java or C#, Go does not rely on inheritance, classes, or heavy dependency injection frameworks.
In Go:
Go’s philosophy is captured in one line:
“Accept interfaces, return structs.”
In other words: introduce abstraction only when it becomes useful, not before.
Let’s imagine two packages:
foo/
foo.goand
bar/
bar.gobar consumes foo.
In the consumer package (bar), not in foo.
Why ?
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{})Suppose:
// package foo
func Compute(ctx context.Context, x int) (int, error) {
...
}You want to isolate Compute inside bar.
Go offers two idiomatic models.
// 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
})// 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)
}| Criteria | Interface | Function Injection |
|---|---|---|
| When to use | Complex behavior | Simple dependency |
| Number of methods | Several | 1 |
| Testability | Very good | Excellent |
| Boilerplate | Medium | Very low |
| Coupling | Low | Slightly higher |
| Refactoring safety | Very strong | May break silently |
| Go idiomatic level | Very idiomatic | Perfectly idiomatic |
Conclusion:
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.
Here is a simple and effective workflow:
Start with simple functions
No premature abstractions.
Let the tests tell you when abstraction is needed
Define interfaces in the consumer
Inject dependencies (struct, interface, or function)
Use minimal fakes in tests
Verify compatibility using:
var _ Interface = (*Impl)(nil)Refactor when tests become difficult.
Go encourages clear, simple, and non-over-engineered code.
Go teams frequently isolate:
time.Now → now func() time.Timerand.Int → randFunc func() inthttp.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.
You asked whether classic mock frameworks are becoming obsolete.
Go encourages simplicity, and this mindset fits perfectly with TDD:
By applying these principles, your Go code will remain: