Go Structs and Interfaces Made Simple

New
13 min read
Deven J.
Deven J.
Published March 17, 2025

When developers transition to Go from object-oriented languages like Java, C++, or Python, they often struggle with Go's approach to types and abstractions. Go intentionally avoids traditional inheritance-based object-oriented programming in favor of composition and interfaces. This design choice wasn't accidental—it reflects a deliberate philosophy about how software should be structured.

As Rob Pike, one of Go's creators, famously said: The bigger the interface, the weaker the abstraction. This principle underpins Go's approach to types and has profound implications for how we build software in the language.

In this blog post, we'll demystify Go's structs and interfaces, exploring how they differ from classes in traditional OOP languages and why Go's approach leads to more maintainable and flexible code. Whether you're new to Go or have been using it without fully grasping these concepts, this guide aims to make these foundational elements click.

Table of Contents

  1. The Historical Context: Why Go Isn't OOP
  2. Structs: Go's Building Blocks
  3. Interfaces: Behavior Over Inheritance
  4. Practical Patterns
  5. Common Pitfalls and How to Avoid Them
  6. Conclusion

The Historical Context: Why Go Isn't OOP

When Go was designed in 2007 by Rob Pike, Robert Griesemer, and Ken Thompson at Google, it emerged from frustration with existing languages. C++ was powerful but complex, with long compile times. Java was verbose and increasingly complex. Dynamic languages were flexible but lacked the safety and performance needed for system programming.

The Go team deliberately avoided traditional object-oriented programming paradigms, particularly inheritance. Go's creators observed that large codebases built with inheritance-heavy OOP languages often became hard to maintain. The problem comes from tight coupling—when class B inherits from class A, changes to class A can unexpectedly break class B. This "fragile base class problem" is a notorious challenge in OOP systems.

Instead of inheritance, Go embraces:

  1. Composition over inheritance: Build complex types by combining simpler ones
  2. Interfaces for abstraction: Define behavior without specifying implementation
  3. Implicit interface satisfaction: No need to declare that a type implements an interface

This approach aligns with a concept called "duck typing" (if it walks like a duck and quacks like a duck, it's a duck), but with Go's static type safety. It encourages modeling software around behavior rather than taxonomic hierarchies.

Structs: Go's Building Blocks

Defining and Creating Structs

Structs in Go are composite data types that group variables under a single name. They're the closest analog to "classes" in traditional OOP languages but without inheritance.

Here's how you define and create structs:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Defining a struct type Person struct { FirstName string LastName string Age int } // Creating a struct instance func main() { // Method 1: Positional initialization (not recommended) p1 := Person{"John", "Doe", 30} // Method 2: Named field initialization (recommended) p2 := Person{ FirstName: "Jane", LastName: "Doe", Age: 28, } // Method 3: Create and then set fields var p3 Person p3.FirstName = "Jim" p3.LastName = "Smith" p3.Age = 35 // Method 4: Using new (returns a pointer) p4 := new(Person) p4.FirstName = "Alice" // Go automatically dereferences pointers to structs }

In this example, we've defined a Person struct and shown four different ways to create instances. The second method with named fields is generally preferred as it's more readable and resilient to changes (if you add or reorder fields in the struct definition).

When working with structs in Go, the most idiomatic approach is to create constructor functions. These factory functions encapsulate the initialization logic and provide a clear, consistent API for creating new instances:

go
1
2
3
4
5
6
7
8
func NewPerson(firstName, lastName string, age int) Person { // Validation logic can be added here return Person{ FirstName: firstName, LastName: lastName, Age: age, } }

Constructor functions are widespread in Go's standard library and are considered a best practice when working with structs.

For comparison, in Java, this would look like:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Person { private String firstName; private String lastName; private int age; public Person(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } // Getters and setters... } // Usage Person p = new Person("John", "Doe", 30);

The Go version is more concise and doesn't require writing constructor methods or getters/setters.

Struct Fields and Methods

In Go, methods are functions associated with a particular type. They have a "receiver" between the func keyword and the method name. This is how we associate methods with structs:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Rectangle struct { Width float64 Height float64 } // Method with a value receiver func (r Rectangle) Area() float64 { return r.Width * r.Height } // Method with a pointer receiver func (r *Rectangle) Scale(factor float64) { r.Width *= factor r.Height *= factor } func main() { r := Rectangle{Width: 10, Height: 5} fmt.Println("Area:", r.Area()) // Output: Area: 50 r.Scale(2) fmt.Println("After scaling - Width:", r.Width, "Height:", r.Height) // Output: After scaling - Width: 20 Height: 10 fmt.Println("New area:", r.Area()) // Output: New area: 200 }

In this example, we've defined two methods on the Rectangle struct:

  • Area() uses a value receiver and simply calculates the rectangle's area
  • Scale() uses a pointer receiver because it modifies the rectangle's dimensions

When to use pointer receivers vs. value receivers:

  1. Use pointer receivers when:

    • The method needs to modify the receiver
    • The receiver is large and copying it would be inefficient
    • Consistency is needed with other methods that use pointer receivers
  2. Use value receivers when:

    • The method doesn't modify the receiver
    • The receiver is small and copying is cheap (like basic types)
    • The method needs to work on copies of the value

In an OOP language like C#, this would look like:

csharp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Rectangle { public double Width { get; set; } public double Height { get; set; } public double Area() { return Width * Height; } public void Scale(double factor) { Width *= factor; Height *= factor; } }

The key difference is that in Go, the method declaration is separate from the struct definition, which supports better code organization and the ability to add methods to any type, not just structs.

Embedding and Composition

Go doesn't have inheritance, but it does have embedding, which is a form of composition. Embedding lets you include one struct type within another:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type Address struct { Street string City string Country string } type Employee struct { Person // Embedded struct (no field name) Address // Another embedded struct Position string Salary float64 } func main() { emp := Employee{ Person: Person{ FirstName: "John", LastName: "Smith", Age: 35, }, Address: Address{ Street: "123 Main St", City: "Boston", Country: "USA", }, Position: "Software Engineer", Salary: 90000, } // Fields of embedded structs can be accessed directly fmt.Println(emp.FirstName) // Output: John fmt.Println(emp.Street) // Output: 123 Main St // Or through the embedded struct's field name fmt.Println(emp.Person.LastName) // Output: Smith fmt.Println(emp.Address.City) // Output: Boston }

Here, Employee embeds both Person and Address, gaining access to their fields as if they were directly declared in Employee. This is called "field promotion" and is one of Go's neat features.

Methods on embedded types are also promoted, so if Person had a FullName() method, Employee would inherit it.

This is similar to C++'s multiple inheritance but without its complexity and ambiguity issues (like the diamond problem). In Java, which doesn't support multiple inheritance, you'd need to use interfaces or delegate to member objects:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Employee { private Person person; private Address address; private String position; private double salary; // Methods to delegate to person and address public String getFirstName() { return person.getFirstName(); } public String getStreet() { return address.getStreet(); } // And so on... }

Go's embedding provides the benefits of composition with less boilerplate code.

Interfaces: Behavior Over Inheritance

Interface Basics

Interfaces in Go define a set of method signatures. Any type that implements all the methods of an interface implicitly satisfies that interface—no "implements" keyword required.

Here's a simple example:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type Geometry interface { Area() float64 Perimeter() float64 } type Rectangle struct { Width, Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r Rectangle) Perimeter() float64 { return 2*r.Width + 2*r.Height } type Circle struct { Radius float64 } func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius } func measure(g Geometry) { fmt.Printf("Area: %.2f\n", g.Area()) fmt.Printf("Perimeter: %.2f\n", g.Perimeter()) } func main() { r := Rectangle{Width: 3, Height: 4} c := Circle{Radius: 5} measure(r) // Works because Rectangle implements Geometry measure(c) // Works because Circle implements Geometry }

In this example, both Rectangle and Circle implement the Geometry interface by providing Area() and Perimeter() methods. The measure function accepts any value that satisfies the Geometry interface.

This is Go's way of enabling polymorphism without inheritance hierarchies.

In Go, the preferred approach is to define interfaces in the package that uses them (the "consumer") rather than in the package that implements them. This is different from many other languages where interfaces are typically defined alongside their implementations.

For example, instead of:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// In the package with the implementation package person type Person struct { Name string Age int } // Defining the interface here type PersonReader interface { GetName() string GetAge() int } func (p Person) GetName() string { return p.Name } func (p Person) GetAge() int { return p.Age }
Ready to integrate? Our team is standing by to help you. Contact us today and launch tomorrow!

The Go way would be:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// In the implementation package package person type Person struct { Name string Age int } func (p Person) GetName() string { return p.Name } func (p Person) GetAge() int { return p.Age } // In the consumer package package consumer // Define the interface here, with only the methods needed type NameGetter interface { GetName() string } func ProcessPerson(p NameGetter) { // Only uses the GetName method fmt.Println(p.GetName()) }

This approach lets each package define exactly what it needs, promotes loose coupling, and makes your code more maintainable and flexible.

Implicit Implementation

One of Go's most distinctive features is implicit interface implementation. Unlike Java or C# where you must explicitly declare that a class implements an interface, in Go a type automatically satisfies an interface if it has all the required methods.

This has profound implications:

  1. Backward compatibility: You can define interfaces for types that already exist, even in packages you don't control.
  2. Loose coupling: Types don't need to know which interfaces they satisfy.
  3. Interface segregation: You can define smaller, focused interfaces tailored to specific needs.

For example, if we have:

go
1
2
3
4
5
6
7
8
9
10
11
12
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } type ReadWriter interface { Reader Writer }

Any type that has both Read() and Write() methods automatically satisfies ReadWriter. You don't need to modify the type or declare the relationship.

In Java, this would require explicit declarations:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface Reader { int read(byte[] p) throws IOException; } public interface Writer { int write(byte[] p) throws IOException; } public interface ReadWriter extends Reader, Writer { // No additional methods needed } public class Buffer implements ReadWriter { @Override public int read(byte[] p) throws IOException { // Implementation } @Override public int write(byte[] p) throws IOException { // Implementation } }

Go's approach reduces boilerplate and enables more flexible designs.

The Empty Interface

The empty interface, denoted as interface{} or just any in newer Go versions, specifies no methods. Since every type implements at least zero methods, every type satisfies the empty interface.

This makes interface{} a way to represent any type, similar to Object in Java or any in TypeScript:

go
1
2
3
4
5
6
7
8
9
10
func printAny(v interface{}) { fmt.Printf("Value: %v, Type: %T\n", v, v) } func main() { printAny(42) // Value: 42, Type: int printAny("hello") // Value: hello, Type: string printAny(true) // Value: true, Type: bool printAny(Rectangle{3, 4}) // Value: {3 4}, Type: main.Rectangle }

While useful, the empty interface sacrifices static type safety, so use it sparingly. In most cases, it's better to define interfaces with specific methods.

Type Assertions and Type Switches

When working with interface values, you often need to access the underlying concrete type. Go provides type assertions and type switches for this purpose:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func describe(i interface{}) { // Type assertion str, ok := i.(string) if ok { fmt.Printf("String value: %s\n", str) return } // Type switch switch v := i.(type) { case int: fmt.Printf("Integer: %d\n", v) case bool: fmt.Printf("Boolean: %t\n", v) case Rectangle: fmt.Printf("Rectangle with area: %.2f\n", v.Area()) default: fmt.Printf("Unknown type: %T\n", v) } } func main() { describe("hello") // String value: hello describe(42) // Integer: 42 describe(true) // Boolean: true describe(Rectangle{5, 7}) // Rectangle with area: 35.00 describe(3.14) // Unknown type: float64 }

Type assertions attempt to access the concrete value of a specific type, while type switches provide a clean way to handle multiple possible types.

Practical Patterns

Implementing Common OOP Patterns in Go

While Go isn't object-oriented in the traditional sense, many OOP patterns can be implemented effectively using Go's structs and interfaces. Let's look at a few examples:

Strategy Pattern

The strategy pattern defines a family of algorithms and makes them interchangeable. In Go:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type SortStrategy interface { Sort([]int) } type QuickSort struct{} func (qs QuickSort) Sort(data []int) { // Implementation of quicksort fmt.Println("Sorting using quicksort") } type MergeSort struct{} func (ms MergeSort) Sort(data []int) { // Implementation of merge sort fmt.Println("Sorting using merge sort") } type Sorter struct { strategy SortStrategy } func (s *Sorter) SetStrategy(strategy SortStrategy) { s.strategy = strategy } func (s *Sorter) Sort(data []int) { s.strategy.Sort(data) } func main() { sorter := Sorter{} // Use quicksort sorter.SetStrategy(QuickSort{}) sorter.Sort([]int{3, 1, 4, 1, 5}) // Switch to merge sort sorter.SetStrategy(MergeSort{}) sorter.Sort([]int{3, 1, 4, 1, 5}) }

Observer Pattern

The observer pattern allows an object to notify other objects of state changes:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type Observer interface { Update(message string) } type Subject struct { observers []Observer message string } func (s *Subject) Attach(observer Observer) { s.observers = append(s.observers, observer) } func (s *Subject) SetMessage(message string) { s.message = message s.notifyAll() } func (s *Subject) notifyAll() { for _, observer := range s.observers { observer.Update(s.message) } } type ConcreteObserver struct { id string } func (c ConcreteObserver) Update(message string) { fmt.Printf("Observer %s received: %s\n", c.id, message) } func main() { subject := Subject{} observer1 := ConcreteObserver{"A"} observer2 := ConcreteObserver{"B"} subject.Attach(observer1) subject.Attach(observer2) subject.SetMessage("Hello Observers!") // Output: // Observer A received: Hello Observers! // Observer B received: Hello Observers! }

Error Handling with Interfaces

Go's error interface is one of the most common applications of interfaces in the language:

go
1
2
3
type error interface { Error() string }

Any type that implements an Error() method satisfies this interface. This simple design allows for rich error handling:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
type NotFoundError struct { Resource string } func (e NotFoundError) Error() string { return fmt.Sprintf("%s not found", e.Resource) } type PermissionError struct { Resource string User string } func (e PermissionError) Error() string { return fmt.Sprintf("user %s does not have permission to access %s", e.User, e.Resource) } func getResource(name string, user string) (string, error) { // Check if resource exists if name == "nonexistent" { return "", NotFoundError{Resource: name} } // Check permissions if user != "admin" && name == "config" { return "", PermissionError{Resource: name, User: user} } return "Resource data", nil } func main() { if res, err := getResource("config", "user"); err != nil { switch e := err.(type) { case NotFoundError: fmt.Println("Not found error:", e.Error()) case PermissionError: fmt.Println("Permission denied:", e.Error()) default: fmt.Println("Unknown error:", err) } } else { fmt.Println("Got resource:", res) } }

This pattern allows for detailed error information while still adhering to the error interface contract.

Testing with Interfaces

Interfaces shine in testing, enabling easy mocking of dependencies:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
type Database interface { GetUser(id int) (string, error) SaveUser(id int, name string) error } type UserService struct { db Database } func (s *UserService) RenameUser(id int, newName string) error { name, err := s.db.GetUser(id) if err != nil { return err } if name == newName { return nil // No change needed } return s.db.SaveUser(id, newName) } // For testing type MockDB struct { users map[int]string } func (m *MockDB) GetUser(id int) (string, error) { name, exists := m.users[id] if !exists { return "", NotFoundError{Resource: "user"} } return name, nil } func (m *MockDB) SaveUser(id int, name string) error { m.users[id] = name return nil } func TestRenameUser(t *testing.T) { mockDB := &MockDB{ users: map[int]string{1: "Alice"}, } service := UserService{db: mockDB} // Test successful rename err := service.RenameUser(1, "Alicia") if err != nil { t.Errorf("Expected no error, got %v", err) } if mockDB.users[1] != "Alicia" { t.Errorf("Expected name to be Alicia, got %s", mockDB.users[1]) } // Test user not found err = service.RenameUser(999, "Nobody") if _, ok := err.(NotFoundError); !ok { t.Errorf("Expected NotFoundError, got %v", err) } }

By defining a Database interface, we can substitute a mock implementation for testing, avoiding the need for an actual database. This is the dependency inversion principle in action.

Common Pitfalls and How to Avoid Them

  1. Overuse of interfaces

    While interfaces are powerful, defining an interface for every type is unnecessary. Follow Rob Pike's advice: "The bigger the interface, the weaker the abstraction." Define interfaces with just the methods you need, and often at the point where they're used, not where types are defined.

  2. Forgetting about interface values being value types

    Interface values in Go are two-word data structures containing a pointer to the concrete value and a pointer to type information. This means they're copied when passed around:

    go
    1
    2
    var x MyInterface = &MyStruct{} y := x // y and x are different interface values pointing to the same struct
  3. Not using pointer receivers consistently

    If some methods on a type use pointer receivers, others should too. Mixing receiver types can lead to confusion:

    go
    1
    2
    3
    4
    5
    6
    func (v Value) Read() string { /* ... */ } func (v *Value) Write(s string) { /* ... */ } var v Value var i Interface = v // Error: Value doesn't implement Interface (Write method has pointer receiver) var i Interface = &v // OK
  4. Embedding types with conflicts

    When embedding multiple types, name conflicts can occur:

    go
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    type A struct { Name string } type B struct { Name string } type C struct { A B } c := C{} c.Name = "test" // Ambiguous: A.Name or B.Name? c.A.Name = "test" // Works
  5. Not understanding nil interfaces

    A nil interface is not the same as an interface containing a nil pointer:

    go
    1
    2
    3
    4
    5
    var p *Person = nil var i interface{} = p fmt.Println(p == nil) // true fmt.Println(i == nil) // false

Conclusion

Go's approach to structs and interfaces offers a refreshing alternative to traditional object-oriented programming. By favoring composition over inheritance and focusing on behavior rather than type hierarchies, Go encourages more flexible, decoupled designs.

The key takeaways from our exploration:

  1. Structs are Go's building blocks, providing a way to group related data without the baggage of class hierarchies.

  2. Methods give behavior to structs, with the choice of value vs. pointer receivers offering fine-grained control over how data is accessed and modified.

  3. Embedding enables composition, letting you build complex types from simpler ones without the pitfalls of inheritance.

  4. Interfaces define behavior contracts, focusing on what types can do rather than what they are.

  5. Implicit implementation reduces coupling, allowing for more adaptable and maintainable code.

As we've seen, Go's design choices weren't made to be different for the sake of being different. They reflect hard-won wisdom about building large, maintainable software systems. By understanding and embracing these patterns, you can write Go code that's not just functional, but elegant and robust.

So next time you're tempted to reach for class inheritance or complex type hierarchies, remember: in Go, simplicity and composition lead to more maintainable and adaptable code.

At Stream, we’re pushing Go to its limits to power real-time experiences for billions of end-users. Ready to shape the future of real-time APIs? Apply here!

Further Reading

  1. Effective Go - Official documentation on Go idioms and practices
  2. Go Interfaces Explained by Jordan Orelli
  3. Understanding Interfaces in Go by Uday Hiwarale
Ready to Increase App Engagement?
Integrate Stream’s real-time communication components today and watch your engagement rate grow overnight!
Contact Us Today!