Skip to main content

Clean Package Design in Go: Single Responsibility and Package Cohesion

Table of Contents
SRP in Go is about package boundaries and exported surfaces, not just splitting methods into files. When a package has one reason to change, you can test it in isolation, swap implementations behind an interface, and reason about its behavior without reading the rest of the codebase.

The Single Responsibility Principle (SRP) is often stated as “a class should have one reason to change.” In Go, there are no classes, but the principle applies with equal force to packages and to the exported types within them. Getting it wrong produces a God service: one package that knows about the database, email, HTTP, and business logic all at once. Getting it right produces a codebase where changing the email provider does not require touching the database layer.

The SRP Violation: The God Service
#

Here is a realistic example of what a violated SRP looks like in Go. This is a UserService that has accumulated too many responsibilities:

user/service.go (BEFORE -- violation)
package user

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "net/smtp"
)

type UserService struct {
    db *sql.DB
}

// CreateUser inserts a user AND sends a welcome email AND logs over HTTP.
// Three reasons to change: DB schema, email provider, logging endpoint.
func (s *UserService) CreateUser(username, email string) error {
    _, err := s.db.Exec(
        "INSERT INTO users (username, email) VALUES ($1, $2)",
        username, email,
    )
    if err != nil {
        return fmt.Errorf("insert user: %w", err)
    }

    // Responsibility 2: email
    auth := smtp.PlainAuth("", "robot@example.com", "secret", "smtp.example.com")
    msg := []byte("Subject: Welcome\r\n\r\nWelcome to the platform, " + username)
    if err := smtp.SendMail("smtp.example.com:587", auth, "robot@example.com",
        []string{email}, msg); err != nil {
        return fmt.Errorf("send welcome email: %w", err)
    }

    // Responsibility 3: audit log over HTTP
    payload, _ := json.Marshal(map[string]string{"event": "user.created", "email": email})
    _, _ = http.Post("https://audit.internal/events", "application/json",
        bytes.NewReader(payload))

    return nil
}

This function has three distinct reasons to change:

  1. The database schema changes (column names, table structure)
  2. The email provider changes (SMTP to SendGrid, or email template changes)
  3. The audit log destination or format changes

Testing CreateUser requires a real database, a real SMTP server, and a real HTTP endpoint. A change to the audit log URL requires touching user creation code.

The Correct Split: Repository, Notification, Orchestrator
#

The fix is to extract each responsibility into its own type, each in its own package, and wire them together in a thin orchestration layer.

storage/user_repository.go
package storage

import (
    "context"
    "database/sql"
    "fmt"
)

type User struct {
    ID       int64
    Username string
    Email    string
}

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, username, email string) (*User, error) {
    var id int64
    err := r.db.QueryRowContext(ctx,
        "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
        username, email,
    ).Scan(&id)
    if err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return &User{ID: id, Username: username, Email: email}, nil
}
notification/service.go
package notification

import (
    "fmt"
    "net/smtp"
)

type SMTPConfig struct {
    Host     string
    Port     int
    Username string
    Password string
    From     string
}

type Service struct {
    cfg SMTPConfig
}

func NewService(cfg SMTPConfig) *Service {
    return &Service{cfg: cfg}
}

func (s *Service) SendWelcome(toEmail, username string) error {
    addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
    auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
    msg := []byte(fmt.Sprintf("Subject: Welcome\r\n\r\nWelcome, %s!", username))
    if err := smtp.SendMail(addr, auth, s.cfg.From, []string{toEmail}, msg); err != nil {
        return fmt.Errorf("send welcome email to %s: %w", toEmail, err)
    }
    return nil
}
user/service.go (AFTER -- orchestration only)
package user

import (
    "context"
    "fmt"

    "example.com/app/notification"
    "example.com/app/storage"
)

// Notifier is the interface this package requires. It does not care about SMTP.
type Notifier interface {
    SendWelcome(email, username string) error
}

// Storer is the interface this package requires. It does not care about SQL.
type Storer interface {
    Create(ctx context.Context, username, email string) (*storage.User, error)
}

type Service struct {
    store    Storer
    notifier Notifier
}

func NewService(store Storer, notifier Notifier) *Service {
    return &Service{store: store, notifier: notifier}
}

func (s *Service) Register(ctx context.Context, username, email string) error {
    user, err := s.store.Create(ctx, username, email)
    if err != nil {
        return fmt.Errorf("register: %w", err)
    }
    if err := s.notifier.SendWelcome(user.Email, user.Username); err != nil {
        // Non-fatal: user is created, notification is best-effort.
        // Log the error but do not roll back.
        return fmt.Errorf("register: send welcome: %w", err)
    }
    return nil
}

Now UserService has one reason to change: the registration business logic. Changing SMTP is a notification problem. Changing the database schema is a storage problem.

Package Cohesion: What Goes Where
#

flowchart LR
    subgraph Before ["Before (God Service)"]
        GS[user.UserService\nDB + Email + HTTP + Logic]
    end

    subgraph After ["After (Decomposed)"]
        US[user.Service\norchestration only]
        UR[storage.UserRepository\nSQL only]
        NS[notification.Service\nSMTP only]
        US -->|Storer interface| UR
        US -->|Notifier interface| NS
    end

The decomposed structure gives you:

  • storage: everything that talks to the database. One reason to change: the persistence mechanism.
  • notification: everything that sends messages to users. One reason to change: the notification channel or template.
  • user: orchestration of domain operations. One reason to change: the registration (or other user lifecycle) business rules.

The Exported Surface Rule
#

Only export what consumers need. If a type is an implementation detail, keep it unexported.

storage/user_repository.go (exported surface)
// Exported: consumers need to call NewUserRepository and use UserRepository.
type UserRepository struct { /* ... */ }
func NewUserRepository(db *sql.DB) *UserRepository { /* ... */ }
func (r *UserRepository) Create(ctx context.Context, username, email string) (*User, error) { /* ... */ }

// Unexported: consumers do not need to know how the query is built.
func (r *UserRepository) buildInsertQuery() string { /* ... */ }

If a consumer imports storage and needs to mock it, they implement the Storer interface from the user package – they never need to depend on the unexported internals of storage.

When SRP Becomes Over-Engineering
#

Not every 20-line struct needs its own package. SRP is a tool for managing change, not a mandate for maximum file count.

Note

If a function legitimately does two things that always change together, splitting them is ceremony, not engineering. The heuristic is: “would a change to this responsibility require me to open files in another package?” If yes, split. If not, keep them together.

A config package that reads environment variables and builds database connection strings is fine. The fact that it touches two concerns (env vars and DB config) is acceptable because they change together (when you change the DB, you change the env var names too).

The Real Go SRP Heuristic: One Reason to Change
#

Go standard library packages are a good reference. net/http handles HTTP. database/sql handles SQL. encoding/json handles JSON encoding. Each one has a clearly scoped surface and a single axis of change.

Apply the same lens to your own packages: if you can describe a package in one sentence without the word “and”, the SRP is probably satisfied.

Tip

The utils or helpers package is almost always an SRP violation waiting to happen. When you find yourself creating one, ask what the common thread is. If there is none, the functions belong in the packages that use them, not in a shared catch-all.

Interface-Based Dependency Injection as the Enabler
#

The mechanism that makes SRP practical in Go is interface-based dependency injection. Define the interface in the package that consumes it, not in the package that implements it.

user/service.go (interfaces at the consumer)
// These interfaces live in the user package, not in storage or notification.
// The user package defines the behavior it needs; storage and notification
// implement it without knowing about the user package.
type Storer interface {
    Create(ctx context.Context, username, email string) (*storage.User, error)
}

type Notifier interface {
    SendWelcome(email, username string) error
}

This inversion means storage does not import user, so there are no circular dependencies. And in tests, you replace both with simple stubs:

user/service_test.go
package user_test

import (
    "context"
    "testing"

    "example.com/app/storage"
    "example.com/app/user"
)

type stubStorer struct{ user *storage.User }
func (s *stubStorer) Create(_ context.Context, username, email string) (*storage.User, error) {
    return &storage.User{ID: 1, Username: username, Email: email}, nil
}

type stubNotifier struct{ called bool }
func (n *stubNotifier) SendWelcome(_, _ string) error { n.called = true; return nil }

func TestRegister_SendsWelcome(t *testing.T) {
    notifier := &stubNotifier{}
    svc := user.NewService(&stubStorer{}, notifier)

    if err := svc.Register(context.Background(), "alice", "alice@example.com"); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !notifier.called {
        t.Fatal("expected welcome notification to be sent")
    }
}

No database. No SMTP server. No HTTP calls. Pure unit test of the business logic.

Common Mistakes
#

God service anti-pattern
A service struct with fields for a DB connection, an HTTP client, an SMTP client, and a Redis client is doing too much. Each dependency signals a responsibility. When you have four dependencies, you likely have four responsibilities that should be split across packages.
Package per struct
Going too far in the other direction: one struct per package. This creates import chains, forces you to export everything, and eliminates the cohesion benefits of keeping related types together. The right unit is a set of related types that change together for the same reason.
Shared utils packages
utils, helpers, common, and shared packages are magnets for unrelated code. When a function ends up in utils, it usually means it belongs in the package that uses it, or it is genuinely cross-cutting (like a retry helper) and deserves its own focused package like retry or backoff.
Circular imports from poor SRP
If package A imports B and B imports A, Go will refuse to compile. This is almost always caused by poor responsibility decomposition: two packages that are actually one responsibility, or a third package that should own the shared type. Interfaces defined at the consumer (as shown above) are the standard solution.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related