Go Embedding: Composition Over Inheritance
Go deliberately omits class-based inheritance. The language designers recognized that deep inheritance hierarchies create fragile, tightly-coupled code that's difficult to refactor. Instead, Go...
Key Insights
- Go’s embedding mechanism promotes fields and methods from inner types to outer types, providing composition-based code reuse without classical inheritance hierarchies
- Interface embedding composes behaviors declaratively, while struct embedding provides implementation reuse with explicit control over method shadowing
- Embedding works best for “has-a” relationships where you want to extend functionality; use explicit fields when the relationship needs to be clear or when you need multiple instances of the same type
Why Go Chose Composition
Go deliberately omits class-based inheritance. The language designers recognized that deep inheritance hierarchies create fragile, tightly-coupled code that’s difficult to refactor. Instead, Go provides embedding—a mechanism that achieves code reuse through composition while maintaining clear, flat structures.
Embedding isn’t just syntactic sugar. It’s a fundamental design choice that encourages you to think about what objects do (interfaces) rather than what they are (class hierarchies). This shift produces more flexible, testable code.
Struct Embedding Basics
Struct embedding works by declaring a field without a name—just a type. The embedded type’s fields and methods are “promoted” to the outer struct, making them accessible as if they belonged to the outer type directly.
package main
import "fmt"
type Person struct {
Name string
Age int
}
func (p Person) Introduce() string {
return fmt.Sprintf("Hi, I'm %s and I'm %d years old", p.Name, p.Age)
}
type Employee struct {
Person // embedded struct
EmployeeID string
Department string
}
func main() {
emp := Employee{
Person: Person{
Name: "Alice",
Age: 30,
},
EmployeeID: "E12345",
Department: "Engineering",
}
// Promoted fields - accessed directly
fmt.Println(emp.Name) // "Alice"
fmt.Println(emp.Age) // 30
// Promoted method
fmt.Println(emp.Introduce()) // "Hi, I'm Alice and I'm 30 years old"
// Original embedded struct still accessible
fmt.Println(emp.Person.Name) // "Alice"
}
The Employee struct gains all of Person’s fields and methods without explicitly defining them. You can still access the embedded Person explicitly using emp.Person, but the promoted access pattern (emp.Name) is cleaner and more idiomatic.
Interface Embedding and Composition
Interface embedding composes multiple interfaces into a single interface. This is how Go’s standard library builds complex interfaces from simple, focused ones.
package main
import (
"fmt"
"io"
"os"
)
// Standard library pattern
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// Composed interface
type ReadWriteCloser interface {
Reader
Writer
Closer
}
// Custom implementation
type FileHandler struct {
file *os.File
}
func (fh *FileHandler) Read(p []byte) (n int, err error) {
return fh.file.Read(p)
}
func (fh *FileHandler) Write(p []byte) (n int, err error) {
return fh.file.Write(p)
}
func (fh *FileHandler) Close() error {
fmt.Println("Closing file with cleanup")
return fh.file.Close()
}
func ProcessData(rwc ReadWriteCloser) error {
defer rwc.Close()
data := []byte("Hello, World!")
_, err := rwc.Write(data)
return err
}
func main() {
file, _ := os.Create("example.txt")
handler := &FileHandler{file: file}
ProcessData(handler)
}
Interface embedding is purely declarative—it doesn’t provide implementation. Any type that satisfies all embedded interfaces automatically satisfies the composed interface. This enables powerful abstraction without coupling.
Method Promotion and Shadowing
When you embed a type, its methods are promoted to the outer type. But if the outer type defines a method with the same signature, it shadows the embedded method.
package main
import (
"fmt"
"time"
)
type Logger struct {
prefix string
}
func (l Logger) Info(msg string) {
fmt.Printf("[INFO] %s: %s\n", l.prefix, msg)
}
func (l Logger) Error(msg string) {
fmt.Printf("[ERROR] %s: %s\n", l.prefix, msg)
}
func (l Logger) Debug(msg string) {
fmt.Printf("[DEBUG] %s: %s\n", l.prefix, msg)
}
type Service struct {
Logger // embedded logger
name string
}
// Shadow the Error method to add custom behavior
func (s Service) Error(msg string) {
// Add timestamp and service context
timestamp := time.Now().Format("2006-01-02 15:04:05")
fmt.Printf("[ERROR] %s [%s] %s: %s\n", timestamp, s.name, s.Logger.prefix, msg)
// Could add additional error tracking here
}
func main() {
svc := Service{
Logger: Logger{prefix: "UserService"},
name: "user-api",
}
// Uses promoted method from Logger
svc.Info("Service started")
svc.Debug("Processing request")
// Uses shadowed method from Service
svc.Error("Database connection failed")
// Can still access original if needed
svc.Logger.Error("Direct logger call")
}
Output:
[INFO] UserService: Service started
[DEBUG] UserService: Processing request
[ERROR] 2024-01-15 10:30:45 [user-api] UserService: Database connection failed
[ERROR] UserService: Direct logger call
Shadowing gives you fine-grained control: use embedded behavior by default, override only where you need customization.
Real-World Pattern: Building Flexible Components
Embedding shines in middleware and decorator patterns. Here’s an HTTP handler pattern that composes functionality:
package main
import (
"fmt"
"net/http"
"time"
)
// Base handler
type BaseHandler struct{}
func (h BaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}
// Logging middleware via embedding
type LoggingHandler struct {
http.Handler // embed interface, not concrete type
}
func (h LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
fmt.Printf("[%s] %s %s\n", start.Format("15:04:05"), r.Method, r.URL.Path)
h.Handler.ServeHTTP(w, r) // delegate to embedded handler
fmt.Printf("Completed in %v\n", time.Since(start))
}
// Auth middleware
type AuthHandler struct {
http.Handler
requiredToken string
}
func (h AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != h.requiredToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
h.Handler.ServeHTTP(w, r)
}
func main() {
base := BaseHandler{}
// Compose handlers through embedding
logged := LoggingHandler{Handler: base}
authenticated := AuthHandler{
Handler: logged,
requiredToken: "secret-token",
}
http.Handle("/", authenticated)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
This pattern creates a chain of responsibility where each layer adds functionality without modifying existing code. You can reorder, add, or remove middleware without changing the handlers themselves.
Common Pitfalls and Best Practices
Pitfall 1: Pointer vs Value Embedding
// Problematic: value embedding with pointer receiver methods
type Database struct {
connected bool
}
func (db *Database) Connect() error {
db.connected = true
return nil
}
type Repository struct {
Database // value embedding
}
func main() {
repo := Repository{}
repo.Connect() // Compiles but doesn't work as expected!
// The method operates on a copy, repo.Database.connected stays false
}
Better: Consistent pointer embedding
type Repository struct {
*Database // pointer embedding
}
func main() {
repo := Repository{Database: &Database{}}
repo.Connect() // Now it works correctly
fmt.Println(repo.connected) // true
}
Pitfall 2: Overusing Embedding for Clarity
// Confusing: what does Employee "do" with Person?
type Employee struct {
Person
Salary float64
}
// Clearer: explicit relationship
type Employee struct {
PersonalInfo Person
Salary float64
}
Use embedding when you want the outer type to behave as the inner type. Use explicit fields when the relationship is containment or when you need multiple instances of the same type.
Best Practice: Embed Interfaces, Not Concrete Types
Embedding interfaces provides maximum flexibility:
type Service struct {
storage Storage // interface, not *PostgresDB
logger Logger // interface, not *FileLogger
}
This keeps your code testable and allows swapping implementations without changing the Service struct.
Composition’s Advantages
Go’s embedding mechanism delivers the benefits of inheritance—code reuse and polymorphism—without the drawbacks. You avoid the fragile base class problem where changes to parent classes break child classes. You sidestep the diamond problem where multiple inheritance creates ambiguity.
Composition through embedding produces flatter, more maintainable structures. When you need to understand a type’s behavior, you look at its definition and the embedded types—no traversing inheritance chains. When you need to modify behavior, you can shadow methods selectively or create new types that embed different combinations of existing types.
Most importantly, embedding encourages interface-based design. By embedding interfaces rather than concrete types, you create loosely coupled systems where components interact through contracts rather than implementations. This is the foundation of testable, flexible software.
The next time you reach for inheritance in another language, consider whether composition would serve you better. In Go, you don’t have a choice—and that constraint produces better designs.