Go's type system is built around structs and interfaces, providing a powerful yet simple way to organize code and define contracts. Here's a comprehensive guide for understanding and using them effectively:
Summary
- Use structs to group related data and define custom types
- Define interfaces to specify behavior contracts that types must implement
- Prefer composition over inheritance through struct embedding
- Keep interfaces small and focused on specific behaviors
- Use proper naming conventions and export rules for public APIs
- Leverage Go's implicit interface satisfaction for flexible design
Structs Fundamentals
Structs are Go's way of creating custom types that group related data together.
Define structs using the type
keyword followed by the struct name and field definitions.
Initialize structs using struct literals or the new()
function for pointer allocation.
Access struct fields using dot notation for both values and pointers.
Embed structs within other structs to achieve composition and code reuse.
Use struct tags to provide metadata for fields, commonly used with JSON encoding.
Export struct fields by capitalizing the first letter to make them accessible from other packages.
Keep structs focused on a single responsibility and avoid overly complex field relationships.
Interfaces Overview
Interfaces define method signatures that types must implement to satisfy the interface contract.
Struct Examples
Basic Struct Definition
A well-structured user management example:
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
IsActive bool `json:"is_active"`
}
type Address struct {
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
ZipCode string `json:"zip_code"`
Country string `json:"country"`
}
type UserProfile struct {
User User `json:"user"`
Address Address `json:"address"`
Bio string `json:"bio,omitempty"`
}
// Constructor function for creating new users
func NewUser(name, email string, age int) *User {
return &User{
Name: name,
Email: email,
Age: age,
IsActive: true,
}
}
// Method to validate user data
func (u *User) IsValid() bool {
return u.Name != "" && u.Email != "" && u.Age > 0
}
// Method to get display name
func (u User) DisplayName() string {
if u.Name != "" {
return u.Name
}
return u.Email
}
This example demonstrates several best practices:
- Struct tags provide JSON serialization metadata for web APIs and data persistence
- Constructor functions follow Go naming conventions with
New
prefix for creating instances - Methods with pointer receivers (
*User
) can modify the struct, while value receivers create copies - Field names are capitalized to export them, making them accessible from other packages
- The
omitempty
tag excludes empty fields from JSON output, useful for optional data
Advanced Struct Composition
A file system representation demonstrating struct embedding:
type FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedTime time.Time `json:"modified_time"`
IsDirectory bool `json:"is_directory"`
}
type Permissions struct {
Owner string `json:"owner"`
Group string `json:"group"`
Mode string `json:"mode"`
}
type File struct {
FileInfo // Embedded struct
Permissions // Embedded struct
Content []byte `json:"content,omitempty"`
Children []*File `json:"children,omitempty"`
}
type Directory struct {
FileInfo // Embedded struct
Permissions // Embedded struct
Files []*File `json:"files"`
}
// Method to add a file to directory
func (d *Directory) AddFile(file *File) {
d.Files = append(d.Files, file)
}
// Method to find file by name
func (d *Directory) FindFile(name string) *File {
for _, file := range d.Files {
if file.Name == name {
return file
}
}
return nil
}
// Method to calculate total size
func (f *File) TotalSize() int64 {
total := f.Size
for _, child := range f.Children {
total += child.TotalSize()
}
return total
}
// Method to check if file is executable
func (f *File) IsExecutable() bool {
return strings.Contains(f.Mode, "x")
}
Key patterns demonstrated:
- Struct embedding provides composition without explicit inheritance, allowing types to gain behavior from embedded structs
- Recursive data structures like file trees use pointers to avoid infinite size and enable shared references
- Methods on embedded structs are automatically promoted to the containing struct, reducing boilerplate code
- Time-based fields use
time.Time
for proper date/time handling with built-in parsing and formatting
Interface Definition and Implementation
Defining contracts with interfaces:
// Writer interface defines types that can write data
type Writer interface {
Write(data []byte) (int, error)
}
// Reader interface defines types that can read data
type Reader interface {
Read(data []byte) (int, error)
}
// ReadWriter combines multiple interfaces
type ReadWriter interface {
Reader
Writer
}
// Closer interface for resources that need cleanup
type Closer interface {
Close() error
}
// ReadWriteCloser combines all three interfaces
type ReadWriteCloser interface {
Reader
Writer
Closer
}
// FileManager handles file operations
type FileManager struct {
BasePath string
MaxSize int64
}
// Implement Writer interface
func (fm *FileManager) Write(data []byte) (int, error) {
if int64(len(data)) > fm.MaxSize {
return 0, fmt.Errorf("data size %d exceeds max size %d", len(data), fm.MaxSize)
}
filename := filepath.Join(fm.BasePath, fmt.Sprintf("file_%d.txt", time.Now().Unix()))
file, err := os.Create(filename)
if err != nil {
return 0, err
}
defer file.Close()
return file.Write(data)
}
// Implement Reader interface
func (fm *FileManager) Read(data []byte) (int, error) {
files, err := os.ReadDir(fm.BasePath)
if err != nil {
return 0, err
}
if len(files) == 0 {
return 0, fmt.Errorf("no files to read")
}
latestFile := filepath.Join(fm.BasePath, files[len(files)-1].Name())
file, err := os.Open(latestFile)
if err != nil {
return 0, err
}
defer file.Close()
return file.Read(data)
}
// MemoryBuffer implements ReadWriter for in-memory operations
type MemoryBuffer struct {
data []byte
cursor int
}
func NewMemoryBuffer() *MemoryBuffer {
return &MemoryBuffer{
data: make([]byte, 0),
}
}
func (mb *MemoryBuffer) Write(data []byte) (int, error) {
mb.data = append(mb.data, data...)
return len(data), nil
}
func (mb *MemoryBuffer) Read(data []byte) (int, error) {
if mb.cursor >= len(mb.data) {
return 0, fmt.Errorf("end of buffer reached")
}
n := copy(data, mb.data[mb.cursor:])
mb.cursor += n
return n, nil
}
// Function that works with any Writer
func WriteLog(w Writer, message string) error {
logEntry := fmt.Sprintf("[%s] %s\n", time.Now().Format(time.RFC3339), message)
_, err := w.Write([]byte(logEntry))
return err
}
// Function that works with any ReadWriter
func ProcessData(rw ReadWriter, processor func([]byte) []byte) error {
buffer := make([]byte, 1024)
n, err := rw.Read(buffer)
if err != nil {
return err
}
processed := processor(buffer[:n])
_, err = rw.Write(processed)
return err
}
Best practices demonstrated in this pattern:
- Small, focused interfaces are easier to implement and test than large ones with many methods
- Interface composition allows building complex contracts from simple, reusable components
- Implicit interface satisfaction means types don't need to declare which interfaces they implement
- Functions accepting interfaces are more flexible than those accepting concrete types
- The empty interface
interface{}
(orany
in Go 1.18+) can hold any type but should be used sparingly
Advanced Interface Patterns
Real-world interface usage with error handling and type assertions:
// Validator interface for data validation
type Validator interface {
Validate() error
}
// Serializer interface for data conversion
type Serializer interface {
Serialize() ([]byte, error)
Deserialize([]byte) error
}
// Repository interface for data persistence
type Repository interface {
Save(interface{}) error
FindByID(int64) (interface{}, error)
Delete(int64) error
}
// Product represents an e-commerce product
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
CategoryID int64 `json:"category_id"`
InStock bool `json:"in_stock"`
}
// Implement Validator interface
func (p *Product) Validate() error {
if p.Name == "" {
return fmt.Errorf("product name cannot be empty")
}
if p.Price <= 0 {
return fmt.Errorf("product price must be greater than 0")
}
if p.CategoryID <= 0 {
return fmt.Errorf("product must belong to a valid category")
}
return nil
}
// Implement Serializer interface
func (p *Product) Serialize() ([]byte, error) {
return json.Marshal(p)
}
func (p *Product) Deserialize(data []byte) error {
return json.Unmarshal(data, p)
}
// ProductRepository implements Repository for products
type ProductRepository struct {
storage map[int64]*Product
nextID int64
}
func NewProductRepository() *ProductRepository {
return &ProductRepository{
storage: make(map[int64]*Product),
nextID: 1,
}
}
func (pr *ProductRepository) Save(item interface{}) error {
product, ok := item.(*Product)
if !ok {
return fmt.Errorf("expected *Product, got %T", item)
}
if validator, ok := item.(Validator); ok {
if err := validator.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
}
if product.ID == 0 {
product.ID = pr.nextID
pr.nextID++
}
pr.storage[product.ID] = product
return nil
}
func (pr *ProductRepository) FindByID(id int64) (interface{}, error) {
product, exists := pr.storage[id]
if !exists {
return nil, fmt.Errorf("product with ID %d not found", id)
}
return product, nil
}
func (pr *ProductRepository) Delete(id int64) error {
if _, exists := pr.storage[id]; !exists {
return fmt.Errorf("product with ID %d not found", id)
}
delete(pr.storage, id)
return nil
}
// Service layer that works with interfaces
type ProductService struct {
repo Repository
}
func NewProductService(repo Repository) *ProductService {
return &ProductService{repo: repo}
}
func (ps *ProductService) CreateProduct(name, description string, price float64, categoryID int64) (*Product, error) {
product := &Product{
Name: name,
Description: description,
Price: price,
CategoryID: categoryID,
InStock: true,
}
if err := ps.repo.Save(product); err != nil {
return nil, fmt.Errorf("failed to create product: %w", err)
}
return product, nil
}
func (ps *ProductService) GetProduct(id int64) (*Product, error) {
item, err := ps.repo.FindByID(id)
if err != nil {
return nil, err
}
product, ok := item.(*Product)
if !ok {
return nil, fmt.Errorf("expected *Product, got %T", item)
}
return product, nil
}
// Generic processor function using interfaces
func ProcessValidatableItems(items []Validator) []error {
var errors []error
for i, item := range items {
if err := item.Validate(); err != nil {
errors = append(errors, fmt.Errorf("item %d: %w", i, err))
}
}
return errors
}
// Type assertion helper function
func AsSerializer(v interface{}) (Serializer, bool) {
serializer, ok := v.(Serializer)
return serializer, ok
}
Advanced patterns demonstrated:
- Type assertions allow checking if a value implements an interface at runtime for optional behavior
- Error wrapping with
fmt.Errorf
and%w
verb preserves error chains for better debugging - Generic processing functions work with any type implementing the required interface contract
- Dependency injection through constructor functions accepting interfaces enables easier testing and modularity
- The repository pattern abstracts data persistence, allowing different storage implementations behind the same interface