GoLang Architecture: Methods, Interfaces, Error Handling & Modules
Moving beyond syntax: A professional guide to structuring Go applications. We cover method sets, interface composition, the 'errors' API, and the complexities of modern dependency management with go.mod.
METHODS
Method Declaration
A method is a function with a special receiver argument that appears between the func keyword and the method name, effectively attaching the function to a type.
func (r ReceiverType) MethodName(params) returnType { // method body } // Example func (u User) FullName() string { return u.FirstName + " " + u.LastName }
Value Receivers
Value receivers operate on a copy of the original value, meaning modifications inside the method don't affect the caller's data—use when you don't need to modify the receiver or when the type is small and cheap to copy.
func (c Circle) Area() float64 { return math.Pi * c.radius * c.radius // c is a copy } // Calling: area := myCircle.Area()
Pointer Receivers
Pointer receivers allow methods to modify the receiver's state and avoid copying large structs—use when you need mutation or when the struct is large.
func (c *Circle) Scale(factor float64) { c.radius *= factor // modifies original } // Both work (Go auto-converts): c := Circle{radius: 5} c.Scale(2) // Go converts to (&c).Scale(2)
Methods vs Functions
Methods are bound to types and enable interface implementation, while functions are standalone—methods provide encapsulation and allow the same name across different types.
// Function - standalone func CalculateArea(c Circle) float64 { return math.Pi * c.radius * c.radius } // Method - bound to type func (c Circle) Area() float64 { return math.Pi * c.radius * c.radius } // Method enables: var s Shape = circle (interface) // Function doesn't participate in interfaces
Methods on Non-Struct Types
You can define methods on any named type you declare (not built-ins directly), including type aliases for primitives, slices, or maps.
type MyInt int func (m MyInt) IsPositive() bool { return m > 0 } type StringSlice []string func (s StringSlice) Join(sep string) string { return strings.Join(s, sep) } // Usage var num MyInt = 42 fmt.Println(num.IsPositive()) // true
Method Sets
A type's method set determines which interfaces it implements: value types include only value receiver methods, while pointer types include both value and pointer receiver methods.
┌─────────────────────────────────────────────────┐ │ METHOD SET RULES │ ├─────────────────────────────────────────────────┤ │ Type T (value): only (t T) methods │ │ Type *T (pointer): (t T) AND (t *T) methods │ └─────────────────────────────────────────────────┘ type Counter struct{ n int } func (c Counter) Value() int { return c.n } // value receiver func (c *Counter) Inc() { c.n++ } // pointer receiver // *Counter has both methods // Counter only has Value()
Method Expressions
Method expressions extract a method into a function value where the receiver becomes the first parameter—useful for functional programming patterns and passing methods as callbacks.
type Adder struct{ value int } func (a Adder) Add(n int) int { return a.value + n } // Method expression - receiver becomes first param f := Adder.Add // func(Adder, int) int result := f(Adder{5}, 3) // 8 // Pointer receiver version pf := (*Counter).Inc // func(*Counter)
Method Values
A method value binds a method to a specific receiver instance, creating a closure that can be called without explicitly passing the receiver.
type Counter struct{ n int } func (c *Counter) Inc() { c.n++ } c := &Counter{n: 10} increment := c.Inc // method value - receiver bound increment() // c.n is now 11 increment() // c.n is now 12 // Useful for callbacks time.AfterFunc(time.Second, c.Inc)
INTERFACES
Interface Declaration
An interface defines a contract of method signatures that types must implement; it specifies behavior without implementation details.
type Reader interface { Read(p []byte) (n int, err error) } type Shape interface { Area() float64 Perimeter() float64 }
Interface Implementation (Implicit)
Go uses implicit interface satisfaction—no implements keyword needed; if a type has all the methods, it automatically implements the interface.
type Shape interface { Area() float64 } type Circle struct{ radius float64 } // Circle implicitly implements Shape func (c Circle) Area() float64 { return math.Pi * c.radius * c.radius } var s Shape = Circle{5} // works automatically
Empty Interface (interface{})
The empty interface interface{} has no methods, so every type satisfies it—used for generic containers before generics existed, but loses type safety.
func PrintAnything(v interface{}) { fmt.Println(v) } PrintAnything(42) PrintAnything("hello") PrintAnything([]int{1, 2, 3}) // Common use: maps with mixed values data := map[string]interface{}{ "name": "Alice", "age": 30, }
any Type (Go 1.18+)
any is a built-in alias for interface{} introduced in Go 1.18 for readability—functionally identical but cleaner syntax.
// These are equivalent var x interface{} var y any func Process(data any) { // same as interface{} } // Type definition in builtin package: // type any = interface{}
Type Assertions
Type assertions extract the concrete type from an interface value—use the comma-ok idiom to safely check without panicking.
var i interface{} = "hello" // Unsafe - panics if wrong type s := i.(string) // Safe - returns ok=false if wrong s, ok := i.(string) if ok { fmt.Println(s) } // Wrong type example n, ok := i.(int) // ok=false, n=0
Type Switches
Type switches allow branching based on an interface's concrete type—cleaner than multiple type assertions.
func describe(i interface{}) { switch v := i.(type) { case int: fmt.Printf("Integer: %d\n", v) case string: fmt.Printf("String: %s\n", v) case bool: fmt.Printf("Boolean: %t\n", v) default: fmt.Printf("Unknown type: %T\n", v) } }
Interface Values
An interface value consists of two components: a type and a value—both must be considered when comparing or checking for nil.
┌──────────────────────────────┐ │ Interface Value │ ├──────────────┬───────────────┤ │ Type │ Value │ │ (*Circle) │ 0xc0001234 │ └──────────────┴───────────────┘ var s Shape = Circle{5} // Type: Circle, Value: Circle{5}
nil Interface Values
An interface is only nil when both type and value are nil—a common gotcha when returning typed nil pointers wrapped in interfaces.
var s Shape = nil // nil interface (type=nil, value=nil) fmt.Println(s == nil) // true var c *Circle = nil s = c // NOT nil! (type=*Circle, value=nil) fmt.Println(s == nil) // false! Common bug! // Safe pattern for returns func GetShape() Shape { var c *Circle = nil if c == nil { return nil // return untyped nil } return c }
Interface Composition
Interfaces can embed other interfaces to create larger contracts—promotes small, focused interfaces (Interface Segregation Principle).
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } // Composed interface type ReadWriter interface { Reader Writer } // Can add more methods too type ReadWriteCloser interface { Reader Writer Close() error }
Common Interfaces (Reader, Writer, Closer, Stringer, Error)
These stdlib interfaces are ubiquitous in Go—learn them well as they enable powerful composition and interoperability.
// io.Reader - read data from source type Reader interface { Read(p []byte) (n int, err error) } // io.Writer - write data to destination type Writer interface { Write(p []byte) (n int, err error) } // io.Closer - release resources type Closer interface { Close() error } // fmt.Stringer - custom string representation type Stringer interface { String() string } // error - built-in error interface type error interface { Error() string }
Pointer vs Value Receiver with Interfaces
When storing a value in an interface, only pointer receivers guarantee the interface can call all methods—value receivers work with both but can't modify.
type Counter interface { Inc() Value() int } type counter struct{ n int } func (c *counter) Inc() { c.n++ } func (c counter) Value() int { return c.n } // Must use pointer for interface var c Counter = &counter{} // ✓ works var c Counter = counter{} // ✗ compile error: counter doesn't have Inc()
ERROR HANDLING
error Interface
The error interface is Go's built-in type for error handling—any type implementing Error() string satisfies it.
type error interface { Error() string } // Usage pattern result, err := SomeFunction() if err != nil { // handle error }
errors.New
errors.New creates a simple error with a static message—use for basic errors without formatting.
import "errors" var ErrNotFound = errors.New("item not found") func FindUser(id int) (*User, error) { if id <= 0 { return nil, errors.New("invalid user id") } // ... }
fmt.Errorf
fmt.Errorf creates formatted error messages with variable interpolation—more flexible than errors.New.
func GetUser(id int) (*User, error) { user, exists := users[id] if !exists { return nil, fmt.Errorf("user with id %d not found", id) } return user, nil }
Error Wrapping (%w)
The %w verb wraps an error while preserving the original, creating an error chain for context—enables errors.Is and errors.As.
func ReadConfig(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("reading config %s: %w", path, err) } // ... } // Error message: "reading config app.yaml: open app.yaml: no such file" // Original error preserved in chain
errors.Is
errors.Is checks if any error in the chain matches a target error—use for sentinel error comparison.
var ErrNotFound = errors.New("not found") err := GetItem(id) if errors.Is(err, ErrNotFound) { // handle not found } // Works through wrapping wrapped := fmt.Errorf("operation failed: %w", ErrNotFound) errors.Is(wrapped, ErrNotFound) // true
errors.As
errors.As extracts a specific error type from the chain—use when you need access to custom error fields/methods.
type QueryError struct { Query string Message string } func (e *QueryError) Error() string { return e.Message } err := ExecuteQuery(q) var qe *QueryError if errors.As(err, &qe) { fmt.Printf("Query failed: %s\n", qe.Query) }
errors.Unwrap
errors.Unwrap returns the next error in the chain—rarely used directly, prefer errors.Is/errors.As.
original := errors.New("database error") wrapped := fmt.Errorf("query failed: %w", original) unwrapped := errors.Unwrap(wrapped) fmt.Println(unwrapped) // "database error" // Returns nil if no wrapped error errors.Unwrap(errors.New("no wrap")) // nil
Custom Error Types
Implement the error interface for rich errors with additional context, fields, and methods.
type ValidationError struct { Field string Message string Code int } func (e *ValidationError) Error() string { return fmt.Sprintf("%s: %s (code: %d)", e.Field, e.Message, e.Code) } func Validate(u User) error { if u.Email == "" { return &ValidationError{ Field: "email", Message: "is required", Code: 1001, } } return nil }
Sentinel Errors
Sentinel errors are package-level exported error variables for comparison—define at package scope for expected error conditions.
// Package-level sentinels var ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrTimeout = errors.New("operation timeout") ) // Usage err := repo.FindUser(id) if errors.Is(err, ErrNotFound) { // handle specifically }
Error Handling Patterns
Common patterns include early returns, error wrapping with context, and centralized handling—avoid silent errors.
// Pattern 1: Early return func Process(id int) error { user, err := GetUser(id) if err != nil { return fmt.Errorf("get user: %w", err) } data, err := FetchData(user) if err != nil { return fmt.Errorf("fetch data: %w", err) } return Save(data) } // Pattern 2: Named return for cleanup func ReadFile(path string) (data []byte, err error) { f, err := os.Open(path) if err != nil { return nil, err } defer func() { if cerr := f.Close(); err == nil { err = cerr } }() return io.ReadAll(f) }
panic and recover
panic stops normal execution and begins unwinding the stack; recover (only in defer) catches panics and resumes normal execution.
func SafeOperation() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic recovered: %v", r) } }() riskyOperation() // might panic return nil } func riskyOperation() { panic("something went wrong") }
When to Use Panic
Use panic only for truly unrecoverable programming errors (bugs), never for expected error conditions—let errors flow through return values.
// ✓ Acceptable panic uses: func MustCompile(pattern string) *Regexp { // "Must" prefix indicates panic re, err := Compile(pattern) if err != nil { panic(err) } return re } // ✓ Index out of bounds (programming error) // ✓ Nil pointer dereference on required dependency // ✓ Unreachable code reached // ✗ Never panic for: // - File not found // - Network errors // - Invalid user input // - Any expected runtime condition
Panic Recovery Patterns
Recover at API boundaries (HTTP handlers, goroutine roots) to prevent crashes; log the panic and stack trace.
// HTTP middleware pattern func RecoveryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if r := recover(); r != nil { log.Printf("panic: %v\n%s", r, debug.Stack()) http.Error(w, "Internal Server Error", 500) } }() next.ServeHTTP(w, r) }) } // Goroutine pattern func SafeGo(f func()) { go func() { defer func() { if r := recover(); r != nil { log.Printf("goroutine panic: %v", r) } }() f() }() }
PACKAGES
Package Organization
Organize packages by domain/functionality, not by type (avoid "models", "utils")—each package should have a clear, single responsibility.
✗ Bad structure: ✓ Good structure: myapp/ myapp/ ├── models/ ├── user/ │ ├── user.go │ ├── user.go │ └── order.go │ ├── repository.go ├── controllers/ │ └── service.go │ ├── user.go ├── order/ │ └── order.go │ ├── order.go └── utils/ │ └── handler.go └── helpers.go └── http/ └── middleware.go
Package Naming Conventions
Use short, lowercase, single-word names; avoid underscores, camelCase, or generic names—the package name is part of the identifier.
// ✓ Good import "encoding/json" json.Marshal() import "net/http" http.Get() // ✗ Bad import "myUtils" // no camelCase import "string_helpers" // no underscores import "common" // too generic import "base" // meaningless // Package name + exported name = readable user.New() // not user.NewUser() http.Client // not http.HTTPClient
Internal Packages
Code in internal/ directories can only be imported by code rooted at the parent of internal—enforced by the compiler for true encapsulation.
mymodule/ ├── internal/ # Only mymodule can import │ └── secret/ │ └── secret.go ├── pkg/ # Public API │ └── api/ │ └── api.go └── cmd/ └── server/ └── main.go # Can import internal/ // Other modules CANNOT import mymodule/internal/secret // Compiler error: use of internal package not allowed
Package Initialization
Each package can have init() functions that run automatically (after variable initialization, before main)—use sparingly, avoid side effects.
package database var db *sql.DB func init() { // Runs automatically before main() // Multiple init() functions allowed, run in order log.Println("database package initializing") } // Initialization order: // 1. Imported packages (depth-first) // 2. Package-level variables (dependency order) // 3. init() functions (in source order) // 4. main()
Circular Dependencies (Avoiding)
Go forbids circular imports—resolve by extracting shared types to a third package, using interfaces, or reorganizing dependencies.
✗ Circular: ✓ Fixed with interface: ┌───────┐ ┌───────┐ ┌───────┐ │ A │ ───── │ B │ │ A │ imports interface └───────┘ └───────┘ └───────┘ ▲ │ │ └───────────────┘ ┌───────┐ │ types │ shared types/interfaces └───────┘ │ ┌───────┐ │ B │ implements interface └───────┘
Vendor Directory
The vendor/ directory contains local copies of dependencies—ensures reproducible builds and works offline; created by go mod vendor.
myproject/ ├── go.mod ├── go.sum ├── main.go └── vendor/ ├── modules.txt # Manifest └── github.com/ └── pkg/ └── errors/ └── errors.go # Vendored dependency # Commands go mod vendor # Create/update vendor go build -mod=vendor # Force vendor use
Module-aware Mode
Go uses module mode by default (GO111MODULE=on since Go 1.16)—manages dependencies via go.mod instead of GOPATH.
# Check mode go env GO111MODULE # Values: # on = module mode (default in 1.16+) # off = GOPATH mode (legacy) # auto = module if go.mod present # Module mode enables: # - go.mod/go.sum dependency tracking # - Semantic versioning # - Reproducible builds # - No GOPATH requirement
GO MODULES
go.mod File
The go.mod file defines module path, Go version, and dependencies—it's the heart of Go's dependency management.
module github.com/myuser/myproject go 1.21 require ( github.com/gin-gonic/gin v1.9.1 github.com/pkg/errors v0.9.1 ) require ( // indirect dependencies (auto-managed) golang.org/x/sys v0.10.0 // indirect ) exclude github.com/old/pkg v1.0.0 replace github.com/broken/pkg => github.com/fixed/pkg v1.1.0
go.sum File
The go.sum file contains cryptographic checksums of dependency contents—ensures integrity and reproducibility; never edit manually.
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkP... github.com/gin-gonic/gin v1.9.1/go.mod h1:RdlWmis8... github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7... github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBF... # Format: module version hash # Two entries per module: # 1. h1:xxx - hash of module contents # 2. /go.mod h1:xxx - hash of go.mod only
Module Versioning
Go modules use semantic versioning (semver)—version tags determine compatibility and minimum version selection.
v1.2.3 │ │ │ │ │ └── PATCH: bug fixes (backwards compatible) │ └──── MINOR: new features (backwards compatible) └────── MAJOR: breaking changes # Pre-release versions v1.0.0-alpha v1.0.0-beta.1 v1.0.0-rc.1 # Version selection require github.com/pkg/foo v1.2.3 # exact require github.com/pkg/foo v1.2 # latest 1.2.x
Semantic Versioning
SemVer provides compatibility guarantees: major changes break API, minor adds features, patch fixes bugs—v0.x.x is always unstable.
┌─────────────────────────────────────────────────┐
│ SEMVER GUARANTEES │
├─────────────────────────────────────────────────┤
│ v0.x.x │ No stability guarantee (development)│
│ v1.0.0 │ Public API is stable │
│ v1.x.y │ Backwards compatible with v1.0.0 │
│ v2.0.0 │ Breaking changes from v1 │
└─────────────────────────────────────────────────┘
// Importing v2+
import "github.com/user/repo/v2"
go mod init
Initializes a new module, creating a go.mod file—typically use your repo URL as the module path.
# Initialize new module go mod init github.com/myuser/myproject # Creates: # go.mod with: # module github.com/myuser/myproject # go 1.21 # For local-only projects go mod init myproject
go mod tidy
Adds missing dependencies and removes unused ones from go.mod/go.sum—run after adding/removing imports.
go mod tidy # What it does: # ✓ Adds missing module requirements # ✓ Removes unused requirements # ✓ Updates go.sum with correct hashes # ✓ Downloads missing modules # Common workflow vim main.go # Add new import go mod tidy # Update go.mod git add go.mod go.sum git commit -m "Add dependency"
go mod download
Downloads modules to local cache without building—useful for CI/Docker layer caching.
go mod download # Download specific module go mod download github.com/gin-gonic/gin@v1.9.1 # Show download cache location go env GOMODCACHE # Usually ~/go/pkg/mod # Dockerfile pattern for layer caching COPY go.mod go.sum ./ RUN go mod download # Cached layer COPY . . RUN go build # Rebuild only on code change
go mod vendor
Creates a vendor directory with all dependencies—for offline builds and full control of dependency code.
go mod vendor # Creates vendor/ with all dependencies # Creates vendor/modules.txt manifest # Build using vendor go build -mod=vendor # Force vendor in go.mod // +build -mod=vendor # When to use: # - Air-gapped environments # - Auditing dependencies # - Ensuring reproducibility
go mod verify
Verifies dependencies haven't been modified since download—security check against tampering.
go mod verify # Output if OK: # all modules verified # Output if tampered: # github.com/pkg/errors v0.9.1: dir has been modified # Use in CI go mod verify || exit 1
go mod graph
Prints the module dependency graph—useful for understanding dependency trees.
go mod graph # Output format: module dependency github.com/myapp github.com/gin-gonic/gin@v1.9.1 github.com/gin-gonic/gin@v1.9.1 github.com/json-iterator/go@v1.1.12 github.com/json-iterator/go@v1.1.12 github.com/modern-go/reflect2@v1.0.2 # Visualize go mod graph | modgraphviz | dot -Tpng -o deps.png
Replacing Dependencies
The replace directive substitutes one module for another—useful for forks, local development, or fixing broken dependencies.
// In go.mod // Replace with fork replace github.com/broken/pkg => github.com/myfork/pkg v1.0.1 // Replace with local path (development) replace github.com/mycompany/shared => ../shared // Replace specific version replace github.com/old/pkg v1.0.0 => github.com/old/pkg v1.0.1 // Multiple replacements replace ( github.com/pkg/a => github.com/pkg/a v1.1.0 github.com/pkg/b => ../local/b )
Pseudo-versions
Pseudo-versions are auto-generated version strings for commits without tags—format encodes timestamp and commit hash.
v0.0.0-20230615145516-abcdef123456 │ │ │ │ │ └── First 12 chars of commit hash │ └── Commit timestamp (YYYYMMDDHHmmss) └── Base version # Get pseudo-version for commit go get github.com/user/repo@abc123 # Types: # vX.0.0-yyyymmddhhmmss-hash (no prior tag) # vX.Y.Z-pre.0.yyyymmddhhmmss-hash (tagged vX.Y.Z-pre) # vX.Y.(Z+1)-0.yyyymmddhhmmss-hash (after tag vX.Y.Z)
Minimum Version Selection
Go uses MVS algorithm: always selects the minimum version that satisfies all requirements—no separate lock file needed, deterministic.
┌─────────────────────────────────────────────┐ │ MVS │ ├─────────────────────────────────────────────┤ │ Your app requires: pkg@v1.2.0 │ │ Dependency A requires: pkg@v1.1.0 │ │ Dependency B requires: pkg@v1.3.0 │ │ │ │ Selected: pkg@v1.3.0 (minimum satisfying) │ └─────────────────────────────────────────────┘ # Not "latest" - just minimum that works # Reproducible without lock file # go.sum ensures integrity
Major Version Suffixes
Modules v2+ must include major version in module path—enables multiple major versions in same build.
// go.mod for v2 module github.com/user/repo/v2 go 1.21 // Importing v2 import "github.com/user/repo/v2" // Can use v1 and v2 together! import ( repov1 "github.com/user/repo" repov2 "github.com/user/repo/v2" ) // Directory structure options: // 1. Branch-based (v2 branch) // 2. Subdirectory (v2/ folder)
Private Modules
Configure Go to access private repositories using GOPRIVATE, authentication tokens, and git config.
# Set private module patterns export GOPRIVATE="github.com/mycompany/*,gitlab.internal.com/*" # Git authentication (choose one): # 1. SSH git config --global url."git@github.com:".insteadOf "https://github.com/" # 2. Token in .netrc echo "machine github.com login oauth2 password ${TOKEN}" >> ~/.netrc # 3. Git credential helper git config --global credential.helper store # Verify go get github.com/mycompany/private-repo
GOPROXY
GOPROXY specifies module download sources—defaults to Google's proxy for speed and availability.
# Default export GOPROXY="https://proxy.golang.org,direct" # Format: comma-separated URLs, "direct" for VCS # "off" disables downloading # Common settings: # Public + fallback to direct export GOPROXY="https://proxy.golang.org,direct" # Corporate proxy export GOPROXY="https://goproxy.mycompany.com,https://proxy.golang.org,direct" # China (blocked regions) export GOPROXY="https://goproxy.cn,direct" # Vendor only (air-gapped) export GOPROXY="off"
GOPRIVATE
GOPRIVATE specifies patterns for private modules that should bypass proxy and checksum database.
export GOPRIVATE="github.com/mycompany/*,*.internal.com" # Equivalent to setting both: # GONOPROXY - skip proxy # GONOSUMDB - skip checksum verification # Patterns: # - Prefix match with / # - Glob with * # - Multiple comma-separated # Verify settings go env GOPRIVATE go env GONOPROXY go env GONOSUMDB
Module Mirrors
Module mirrors/proxies cache modules for reliability, speed, and compliance—can run private proxies like Athens or Artifactory.
┌──────────┐ ┌─────────────┐ ┌─────────────┐ │ go get │────▶│ GOPROXY │────▶│ Module │ │ │ │ (mirror) │ │ Origin │ └──────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ │ Cache │ │ (immutable) │ └─────────────┘ # Popular proxies: # - proxy.golang.org (Google, default) # - goproxy.io (China-friendly) # - Athens (self-hosted) # - Artifactory (enterprise) # Self-hosted Athens docker run -p 3000:3000 gomods/athens:latest export GOPROXY="http://localhost:3000,direct"
This covers the intermediate Go topics. These concepts form the foundation for building robust, production-ready Go applications. The interface and error handling patterns are particularly important for writing idiomatic Go code.