After learning the basic building blocks of Go (variables, control flow, functions, and data structures), the next step is understanding how to model real-world concepts.
In Go we primarily use three tools for this:
- Structs — to define data
- Methods — to define behavior on that data
- Interfaces — to define contracts and enable polymorphism
This post will give you a solid, practical understanding of all three.
1. Structs — Defining Your Data
A struct is a typed collection of fields:
type User struct {
ID int
Name string
Email string
CreatedAt time.Time
}
Creating struct values
// Positional (not recommended for many fields)
u1 := User{1, "Leonardo", "leo@example.com", time.Now()}
// Named fields (preferred)
u2 := User{
ID: 2,
Name: "Ana",
Email: "ana@example.com",
}
// Pointer to struct
u3 := &User{Name: "Carlos"}
Accessing fields
fmt.Println(u2.Name) // Ana
u2.Email = "new@email.com"
Embedded structs (composition)
Go prefers composition over inheritance:
type Address struct {
City string
Country string
}
type Person struct {
Name string
Address // embedded
}
p := Person{
Name: "Maria",
Address: Address{
City: "São Paulo",
Country: "Brazil",
},
}
fmt.Println(p.City) // promoted field
2. Methods — Adding Behavior
Methods are functions with a receiver:
func (u User) DisplayName() string {
return u.Name + " <" + u.Email + ">"
}
Value receiver vs Pointer receiver
// Value receiver (works on a copy)
func (u User) FullName() string {
return u.Name
}
// Pointer receiver (can modify the original)
func (u *User) UpdateEmail(email string) {
u.Email = email
}
When to use each:
| Receiver Type | Use when… |
|---|---|
| Value | Method does not need to modify the receiver |
| Pointer | Method needs to modify the receiver, or the struct is large |
A good rule of thumb: if any method on the type has a pointer receiver, be consistent and use pointer receivers for most methods.
3. Interfaces — Defining Contracts
An interface defines a set of method signatures. Any type that implements those methods automatically satisfies the interface (implicit implementation).
type Speaker interface {
Speak() string
}
type Dog struct {
Name string
}
func (d Dog) Speak() string {
return d.Name + " says woof!"
}
type Cat struct {
Name string
}
func (c Cat) Speak() string {
return c.Name + " says meow!"
}
Both Dog and Cat implement Speaker without explicitly saying so.
func makeSound(s Speaker) {
fmt.Println(s.Speak())
}
makeSound(Dog{Name: "Rex"})
makeSound(Cat{Name: "Mimi"})
The empty interface
var anything interface{} // can hold any value
Since Go 1.18 we prefer any (an alias for interface{}).
Common interfaces in the standard library
io.Reader/io.Writerfmt.Stringererrorhttp.Handler
4. Practical Example — A Simple Repository Pattern
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
}
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) FindByID(ctx context.Context, id int) (*User, error) {
// implementation...
return nil, nil
}
func (r *PostgresUserRepository) Save(ctx context.Context, user *User) error {
// implementation...
return nil
}
This pattern is extremely common in real Go services. It makes your code testable and allows you to swap implementations easily.
5. Best Practices
Structs
- Keep structs focused (single responsibility).
- Use field tags for JSON, DB, validation, etc.
- Prefer composition over deep embedding.
Methods
- Keep methods small and focused.
- Be consistent with value vs pointer receivers.
- Don’t put business logic that belongs to another domain in the wrong type.
Interfaces
- Define interfaces where they are used, not where they are implemented (accept interfaces, return structs).
- Keep interfaces small (often 1–3 methods).
- Avoid premature interface abstraction.
6. Common Mistakes
- Creating large “God” structs that know too much.
- Defining interfaces too early (before you have multiple implementations).
- Using pointer receivers inconsistently.
- Returning interfaces from constructors when a concrete type would be clearer.
Conclusion
Structs, methods, and interfaces are the core tools for organizing code in Go. When used well, they lead to clean, testable, and maintainable systems.
Key takeaways:
- Use structs to model data.
- Use methods to attach behavior.
- Use interfaces to define contracts and enable flexible design.
In the next posts we will apply these concepts to more advanced topics such as building gRPC services and writing robust tests.
Happy coding!