Struct Embedding and Composition Patterns in Go
Learn Go composition over inheritance with struct embedding, interface embedding, and practical patterns for clean architecture.
What you'll learn
- ✓How struct embedding works and how it differs from inheritance
- ✓Interface embedding for composing behaviors
- ✓Practical composition patterns for services and middleware
Prerequisites
- •Basic Go knowledge
- •Familiarity with structs and interfaces
Go does not have inheritance. It has composition, and struct embedding is the mechanism that makes composition feel natural. This guide explains how embedding works, when to use it, and common patterns for production code.
How Struct Embedding Works
When you embed a type inside a struct, its methods and fields are “promoted” to the outer struct. You can call them directly without going through a field name.
type Logger struct {
Prefix string
}
func (l *Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.Prefix, msg)
}
type Server struct {
Logger // embedded, not a named field
Port int
}
func main() {
s := Server{
Logger: Logger{Prefix: "HTTP"},
Port: 8080,
}
s.Log("starting server") // promoted method
// equivalent to s.Logger.Log("starting server")
}
Output: [HTTP] starting server
Embedding Is Not Inheritance
The key difference: when you call a promoted method, the receiver is still the embedded type, not the outer type.
type Base struct{}
func (b *Base) Name() string { return "Base" }
func (b *Base) Greet() string { return "Hello from " + b.Name() }
type Derived struct {
Base
}
func (d *Derived) Name() string { return "Derived" }
func main() {
d := &Derived{}
fmt.Println(d.Greet()) // "Hello from Base", not "Hello from Derived"
}
There is no virtual method dispatch. Base.Greet() calls Base.Name(), not Derived.Name(). If you need polymorphism, use interfaces.
Interface Satisfaction via Embedding
Embedding a type that implements an interface makes the outer type also satisfy that interface.
type ReadCloser struct {
io.Reader // has Read method
io.Closer // has Close method
}
// ReadCloser now implements io.ReadCloser without writing any methods
This is how the standard library builds complex interfaces from simple ones:
// From the io package
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
Pattern: Embedding for Default Behavior
Provide a base implementation and let the outer type override specific methods.
type Notifier interface {
Notify(ctx context.Context, event Event) error
Format(event Event) string
}
type BaseNotifier struct{}
func (b *BaseNotifier) Format(event Event) string {
return fmt.Sprintf("[%s] %s", event.Type, event.Message)
}
type SlackNotifier struct {
BaseNotifier
WebhookURL string
}
func (s *SlackNotifier) Notify(ctx context.Context, event Event) error {
msg := s.Format(event) // uses BaseNotifier.Format
return postToSlack(ctx, s.WebhookURL, msg)
}
type EmailNotifier struct {
BaseNotifier
SMTPAddr string
}
func (e *EmailNotifier) Format(event Event) string {
// Override the default format for email
return fmt.Sprintf("Subject: %s\n\n%s", event.Type, event.Message)
}
func (e *EmailNotifier) Notify(ctx context.Context, event Event) error {
msg := e.Format(event) // uses EmailNotifier.Format (overridden)
return sendEmail(ctx, e.SMTPAddr, msg)
}
Pattern: Composition with Dependency Injection
Instead of embedding, hold dependencies as named fields. This is the most common pattern for services.
type OrderService struct {
repo OrderRepository
payments PaymentGateway
notifier Notifier
logger *slog.Logger
}
func NewOrderService(
repo OrderRepository,
payments PaymentGateway,
notifier Notifier,
logger *slog.Logger,
) *OrderService {
return &OrderService{
repo: repo,
payments: payments,
notifier: notifier,
logger: logger,
}
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
if err := s.payments.Charge(ctx, order.Total); err != nil {
return fmt.Errorf("charge payment: %w", err)
}
if err := s.repo.Save(ctx, order); err != nil {
return fmt.Errorf("save order: %w", err)
}
s.logger.Info("order placed", "order_id", order.ID, "total", order.Total)
return nil
}
Named fields (not embedding) are preferred for dependencies because:
- The API is explicit. Callers know exactly what
OrderServiceneeds. - No accidental method promotion. The service does not accidentally expose
repo.Deleteat the top level. - Easier to test. Swap dependencies with mocks.
Pattern: Embedding Mutex for Protected State
Embed a sync.Mutex to protect a struct’s fields. This is idiomatic for small, self-contained types.
type SafeMap struct {
sync.RWMutex
data map[string]int
}
func NewSafeMap() *SafeMap {
return &SafeMap{data: make(map[string]int)}
}
func (m *SafeMap) Get(key string) (int, bool) {
m.RLock()
defer m.RUnlock()
v, ok := m.data[key]
return v, ok
}
func (m *SafeMap) Set(key string, value int) {
m.Lock()
defer m.Unlock()
m.data[key] = value
}
Embedding promotes Lock, Unlock, RLock, and RUnlock directly onto SafeMap. Some teams prefer this; others prefer a named mu sync.RWMutex field to keep the locking methods private.
Pattern: Wrapping with Embedding
Add behavior around an existing type by embedding it and overriding specific methods.
type LoggingRoundTripper struct {
http.RoundTripper
Logger *slog.Logger
}
func (l *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := l.RoundTripper.RoundTrip(req)
duration := time.Since(start)
if err != nil {
l.Logger.Error("http request failed",
"method", req.Method,
"url", req.URL.String(),
"duration", duration,
"error", err,
)
return nil, err
}
l.Logger.Info("http request",
"method", req.Method,
"url", req.URL.String(),
"status", resp.StatusCode,
"duration", duration,
)
return resp, nil
}
Usage:
client := &http.Client{
Transport: &LoggingRoundTripper{
RoundTripper: http.DefaultTransport,
Logger: slog.Default(),
},
}
This is the decorator pattern via embedding.
Pattern: Embedding Interfaces in Structs
Embed an interface in a struct to create a partial mock or a decorator that only overrides some methods.
type MockStore struct {
Store // embed the interface
GetFunc func(ctx context.Context, id string) (*Item, error)
}
func (m *MockStore) Get(ctx context.Context, id string) (*Item, error) {
if m.GetFunc != nil {
return m.GetFunc(ctx, id)
}
return m.Store.Get(ctx, id) // delegate to embedded
}
This is useful in tests when an interface has many methods but you only want to override one or two.
When to Embed vs When to Compose
Embed when:
- You want the outer type to satisfy an interface automatically.
- You are building a decorator that wraps and extends behavior.
- The promoted methods make sense as part of the outer type’s API.
Use named fields when:
- The inner type is a dependency (database, logger, HTTP client).
- Promoting methods would leak implementation details.
- You want to keep the outer type’s API surface small.
Avoiding Ambiguity
If two embedded types have a method with the same name, the compiler reports an ambiguity error.
type A struct{}
func (a A) Hello() string { return "A" }
type B struct{}
func (b B) Hello() string { return "B" }
type C struct {
A
B
}
// c.Hello() is ambiguous and will not compile
// Fix by defining Hello() on C explicitly:
func (c C) Hello() string {
return c.A.Hello() // or c.B.Hello()
}
Summary
- Embedding promotes methods and fields. It is composition, not inheritance.
- There is no virtual dispatch: promoted methods use the embedded type as the receiver.
- Embed interfaces to compose behaviors (
io.ReadWriteCloser). - Use named fields for dependencies to keep APIs explicit and testable.
- Embed
sync.Mutexfor self-protecting types. - Embed an interface in a struct for partial mocks and decorators.
- When two embeddings conflict, resolve ambiguity with an explicit method.
Related articles
- Go Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.
- Go Dependency Injection Patterns in Go
Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Database Patterns with sqlx and pgx
Master Go database patterns using sqlx and pgx for PostgreSQL including connection pooling, transactions, batch operations, and repository design.