Interfaces in Go: Decoupling Components for Flexible and Testable Web Apps

Interfaces in Go are the glue that allows you to decouple the components of a web app: handlers, services, repositories, external clients. Thanks to interfaces, we can test in isolation, swap implementations (e.g., from in-memory to database), and keep code clean.

When it makes sense to use an interface

  • You want to change the implementation without touching the rest of the code (e.g., local storage vs. Postgres).
  • You want to test a handler without starting a DB or an external service.
  • You’re modeling behavior (a contract), not a type hierarchy.

A minimal example: handler + repository

We start from a UserRepository interface and an HTTP handler that depends on this interface instead of a concrete struct.

package main

import (
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"strconv"
)

type User struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

// Interface: contract for our user storage.
type UserRepository interface {
	FindByID(id int64) (User, error)
	Save(u User) (User, error)
}

// In-memory implementation (handy for demos and local tests).
type memoryRepo struct {
	data map[int64]User
	next int64
}

func NewMemoryRepo() *memoryRepo {
	return &memoryRepo{data: map[int64]User{}, next: 1}
}

func (m *memoryRepo) FindByID(id int64) (User, error) {
	u, ok := m.data[id]
	if !ok {
		return User{}, errors.New("not found")
	}
	return u, nil
}

func (m *memoryRepo) Save(u User) (User, error) {
	u.ID = m.next
	m.next++
	m.data[u.ID] = u
	return u, nil
}

// Handler depends on the interface, not the implementation.
type UserHandler struct {
	repo UserRepository
}

func NewUserHandler(r UserRepository) *UserHandler {
	return &UserHandler{repo: r}
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
	var payload struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.Name == "" {
		http.Error(w, "invalid payload", http.StatusBadRequest)
		return
	}
	u, err := h.repo.Save(User{Name: payload.Name})
	if err != nil {
		http.Error(w, "cannot save", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(u)
}

func (h *UserHandler) GetByID(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil || id <= 0 {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	u, err := h.repo.FindByID(id)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(u)
}

func main() {
	repo := NewMemoryRepo()            // Concrete type
	handler := NewUserHandler(repo)    // Depends on interface

	http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			handler.Create(w, r)
			return
		}
		if r.Method == http.MethodGet {
			handler.GetByID(w, r)
			return
		}
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	})

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Swapping the implementation (e.g., SQL database)

We can create another implementation that satisfies UserRepository but uses a database. The rest of the code stays the same.

package dbrepo

import (
	"database/sql"
	"errors"

	_ "github.com/lib/pq"
)

type User struct {
	ID   int64
	Name string
}

type SQLRepo struct {
	db *sql.DB
}

func NewSQLRepo(db *sql.DB) *SQLRepo { return &SQLRepo{db: db} }

func (r *SQLRepo) FindByID(id int64) (User, error) {
	var u User
	err := r.db.QueryRow(`SELECT id, name FROM users WHERE id = $1`, id).
		Scan(&u.ID, &u.Name)
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, errors.New("not found")
	}
	return u, err
}

func (r *SQLRepo) Save(u User) (User, error) {
	err := r.db.QueryRow(`INSERT INTO users(name) VALUES($1) RETURNING id`, u.Name).
		Scan(&u.ID)
	return u, err
}
package main

import (
	"database/sql"
	"log"
	"os"

	_ "github.com/lib/pq"
	"myapp/dbrepo"
)

func main() {
	dsn := os.Getenv("DATABASE_URL")
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		log.Fatal(err)
	}
	repo := dbrepo.NewSQLRepo(db) // Replace in-memory with Postgres
	handler := NewUserHandler(repo)

	// ... routing as before
}

Example SQL schema

CREATE TABLE users (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

Testing handlers with a mock

In tests we define a fake repository that implements the interface. This way we can test HTTP logic without real dependencies.

package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

type mockRepo struct {
	users map[int64]User
	save  func(User) (User, error)
	find  func(int64) (User, error)
}

func (m *mockRepo) Save(u User) (User, error)   { return m.save(u) }
func (m *mockRepo) FindByID(id int64) (User, error) { return m.find(id) }

func TestCreateUser(t *testing.T) {
	h := NewUserHandler(&mockRepo{
		save: func(u User) (User, error) {
			u.ID = 123
			return u, nil
		},
	})

	body, _ := json.Marshal(map[string]string{"name": "Ada"})
	req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
	w := httptest.NewRecorder()

	h.Create(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d", w.Code)
	}
}

Loose coupling with external clients

Interfaces aren’t just for storage. Here’s an abstract email client, useful for switching providers or disabling sending in tests.

type Mailer interface {
	Send(to, subject, body string) error
}

type noopMailer struct{}

func (noopMailer) Send(to, subject, body string) error { return nil }

// In production you might have an SMTP mailer or an external provider.

Practical guidelines

  • Depend on interfaces, not implementations: accept the interface in your component’s constructor (handler, service).
  • Small interfaces: prefer specific contracts (e.g., FindByID and Save) over “mega-interfaces.”
  • Define the interface on the consumer side: put it in the package that uses it; implementations live elsewhere.
  • Avoid premature interfaces: if there are no alternatives or tests that benefit, start with a concrete type.
  • Use error wrapping and context: distinguish “not found” from system errors.

Package structure

A simple layout to separate contracts and implementations.

myapp/
├── cmd/api/main.go         # app wiring
├── internal/http           # handlers, middleware (consume interfaces)
├── internal/domain         # entities and contracts (minimal interfaces)
├── internal/repository     # implementations (memory, sql, etc.)
└── internal/external       # external clients (mailer, http client ...)

Middleware using an interface

Example of middleware that depends on a logging interface so you can swap strategies (stdout, JSON, file, cloud).

type Logger interface {
	Info(msg string, kv ...any)
	Error(err error, msg string, kv ...any)
}

func Logging(l Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			l.Info("request", "method", r.Method, "path", r.URL.Path)
			next.ServeHTTP(w, r)
		})
	}
}

Final wiring

In main choose the interface implementations and inject them into the components.

func main() {
	// repo := dbrepo.NewSQLRepo(db) // production
	repo := NewMemoryRepo()          // local / manual test

	handler := NewUserHandler(repo)

	mux := http.NewServeMux()
	mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodPost:
			handler.Create(w, r)
		case http.MethodGet:
			handler.GetByID(w, r)
		default:
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		}
	})

	http.ListenAndServe(":8080", mux)
}

Quick checklist

  • Did I define the interface where it’s consumed?
  • Is it small enough to be easy to implement and test?
  • Can I swap the implementation without changing consumers?
  • Do I have mock/fake-based tests using the interface?

Conclusion

Interfaces in Go make web apps more modular, testable, and easier to evolve. Start concrete, then extract an interface when you need flexibility (new implementations or tests). Keep contracts small and close to their consumers—your future self will thank you.

Back to top