Struct Embedding in Go Instead of Inheritance

1. Overview

If you’re familiar with Java or C#, your first question when writing Go is usually: “Where’s extends?”. Go has no inheritance. Instead, Go has struct embedding: embedding one type into another struct to reuse fields and methods.

This article explains how embedding works, how it differs from inheritance, and common pitfalls. After reading, you’ll be able to use embedding to share behavior, write clean decorators, and avoid its characteristic errors.

2. Basic Struct Embedding

Embedding is declaring a field with only a type, no name:

package main

import "fmt"

type Address struct {
	Street string
	City   string
}

func (a Address) FullAddress() string {
	return a.Street + ", " + a.City
}

type Customer struct {
	Name string
	Address // embedded field
}

func main() {
	c := Customer{
		Name:    "Lan",
		Address: Address{Street: "12 Lê Lợi", City: "Huế"},
	}

	fmt.Println(c.City)          // field promoted
	fmt.Println(c.FullAddress()) // method promoted
	fmt.Println(c.Address.City)  // explicit access
}
Huế
12 Lê Lợi, Huế
Huế

Analysis:

  • Address sits inside Customer with no field name. Go automatically names the field Address, which is the type’s name.
  • The City field and FullAddress() method of Address are promoted to Customer. So you can write c.City instead of c.Address.City.
  • When initializing with a composite literal, you still have to use the field name Address:. There’s no syntax to directly fill City into Customer.

At its core, Customer contains an Address. This is a has-a relationship, just with some syntactic convenience added by the compiler. Customer is not an Address. You can’t pass a Customer to a function that expects Address:

func printCity(a Address) { fmt.Println(a.City) }

// printCity(c)         // compile error: cannot use c (type Customer) as Address
printCity(c.Address)    // OK

3. Method “Overriding” via Shadowing

If the outer struct declares a method with the same name as a method of the embedded type, the method of the outer struct will shadow the inner one:

package main

import "fmt"

type Logger struct {
	Prefix string
}

func (l Logger) Log(msg string) {
	fmt.Println(l.Prefix + msg)
}

type OrderService struct {
	Logger
}

func (s OrderService) Log(msg string) {
	s.Logger.Log("[order] " + msg) // call the method of the embedded type
}

func main() {
	s := OrderService{Logger: Logger{Prefix: "> "}}
	s.Log("created #1001")
	s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
  • s.Log(...) calls the method of OrderService, because it’s at a shallower depth.
  • s.Logger.Log(...) can still call the original. This is equivalent to super.log(...) in Java.

It looks like overriding at first glance. But this is exactly where many people stumble, as the next section shows.

4. The Biggest Pitfall: No Virtual Dispatch

In Java, if a parent class calls this.channel() and a subclass overrides channel(), the subclass version runs. Go does not do this:

package main

import "fmt"

type Notifier struct{}

func (n Notifier) Channel() string { return "email" }

func (n Notifier) Send(msg string) string {
	return "[" + n.Channel() + "] " + msg
}

type SMSNotifier struct {
	Notifier
}

func (s SMSNotifier) Channel() string { return "sms" }

func main() {
	s := SMSNotifier{}
	fmt.Println(s.Channel())
	fmt.Println(s.Send("Đơn hàng đã giao"))
}
sms
[email] Đơn hàng đã giao

s.Send(...) is actually s.Notifier.Send(...). Inside Send, the receiver n has type Notifier. It has no idea it’s sitting inside a SMSNotifier, so n.Channel() always returns "email".

This is the core difference. The embedded type has no backward reference to the struct containing it. The Java-style “template method” pattern doesn’t apply directly.

4.1. The Fix: Separate the Varying Part into an Interface

If you need behavior that can change, pass it in explicitly:

package main

import "fmt"

type Channel interface {
	Name() string
}

type Email struct{}

func (Email) Name() string { return "email" }

type SMS struct{}

func (SMS) Name() string { return "sms" }

type Notifier struct {
	Channel Channel
}

func (n Notifier) Send(msg string) string {
	return "[" + n.Channel.Name() + "] " + msg
}

func main() {
	fmt.Println(Notifier{Channel: SMS{}}.Send("Đơn hàng đã giao"))
	fmt.Println(Notifier{Channel: Email{}}.Send("Hóa đơn tháng 10"))
}
[sms] Đơn hàng đã giao
[email] Hóa đơn tháng 10

Now the varying part (Channel) is an explicit dependency. This is more “Go” than trying to emulate overriding.

5. Embedding and Interfaces

Interfaces in Go are satisfied implicitly. Promoted methods are also counted in the method set of the outer struct. So embedding helps a struct satisfy an interface without rewriting methods:

package main

import "fmt"

type Describer interface {
	FullAddress() string
}

type Address struct{ Street, City string }

func (a Address) FullAddress() string { return a.Street + ", " + a.City }

type Warehouse struct {
	Code string
	Address
}

func describe(d Describer) { fmt.Println(d.FullAddress()) }

func main() {
	w := Warehouse{Code: "HN-01", Address: Address{"KCN Thăng Long", "Hà Nội"}}
	describe(w) // Warehouse satisfies Describer thanks to the promoted method
}
KCN Thăng Long, Hà Nội

5.1. Note on Pointer Receivers

The method set rules for embedding are as follows:

Declaration in S Method set of S Method set of *S
embed T method receiver T method receiver T and *T
embed *T method receiver T and *T method receiver T and *T

For example, if Counter has func (c *Counter) Inc() and you embed Counter (not a pointer) into Stats, then only *Stats satisfies an interface with Inc(). The value Stats does not. Compile errors like “Stats does not implement Incrementer (method Inc has pointer receiver)” often come from this.

6. Embedding an Interface in a Struct: Clean Decorators

You can embed an interface in a struct. The struct will automatically have all the methods of the interface, with each method delegated to the inner value. You only need to override the methods you want to change:

package payment

import "fmt"

type PaymentService interface {
	Charge(orderID string, amount int) error
	Refund(orderID string) error
}

type LoggingPayment struct {
	PaymentService           // delegate all methods
	Logs           []string
}

func (l *LoggingPayment) Charge(orderID string, amount int) error {
	l.Logs = append(l.Logs, fmt.Sprintf("charge %s: %d", orderID, amount))
	return l.PaymentService.Charge(orderID, amount)
}
  • LoggingPayment only overrides Charge. Refund is automatically delegated to the inner PaymentService.
  • *LoggingPayment satisfies PaymentService, so you can wrap it around any implementation without the caller knowing.

When an interface has ten methods and you only need to intercept one, this technique saves a lot of repetitive code.

Note: If the embedded interface field is nil, the code still compiles fine. But calling a method that isn’t overridden (like Refund) will cause a nil pointer dereference panic at runtime. Always initialize it through a constructor, like NewLoggingPayment(inner PaymentService).

7. Testing Delegation Behavior

Go uses the standard testing package instead of JUnit. The test below checks two things: Charge is logged and still calls the underlying service, and Refund is delegated without logging.

package payment

import "testing"

type fakePayment struct {
	charged  []string
	refunded []string
}

func (f *fakePayment) Charge(orderID string, amount int) error {
	f.charged = append(f.charged, orderID)
	return nil
}

func (f *fakePayment) Refund(orderID string) error {
	f.refunded = append(f.refunded, orderID)
	return nil
}

func TestLoggingPayment(t *testing.T) {
	inner := &fakePayment{}
	var svc PaymentService = &LoggingPayment{PaymentService: inner}

	if err := svc.Charge("ORD-1", 250000); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if err := svc.Refund("ORD-1"); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	lp := svc.(*LoggingPayment)
	if len(lp.Logs) != 1 || lp.Logs[0] != "charge ORD-1: 250000" {
		t.Errorf("logs = %v, want [charge ORD-1: 250000]", lp.Logs)
	}
	if len(inner.charged) != 1 || len(inner.refunded) != 1 {
		t.Errorf("inner not called: charged=%v refunded=%v", inner.charged, inner.refunded)
	}
}

Run the test:

go test ./...
ok  	example.com/shop/payment	0.002s

The line var svc PaymentService = &LoggingPayment{...} is also a compile-time check: if *LoggingPayment doesn’t satisfy the interface, the test won’t build. In production code, you can use the familiar idiom var _ PaymentService = (*LoggingPayment)(nil) for the same purpose.

8. Name Conflicts and Common Errors

8.1. Ambiguous Selector

When two embedded types at the same depth have the same field or method name, accessing through the outer struct causes a compile error:

type Billing struct{ Email string }
type Shipping struct{ Email string }

type Order struct {
	Billing
	Shipping
}

func main() {
	o := Order{}
	// fmt.Println(o.Email)       // compile error: ambiguous selector o.Email
	fmt.Println(o.Billing.Email)  // OK
}

The struct Order is still valid. The error only appears when you access o.Email. The fix is to call explicitly, or declare Email directly on Order to shadow both.

8.2. Accidentally Exposing API: The sync.Mutex Example

type Inventory struct {
	sync.Mutex
	stock map[string]int
}

This code runs fine. But now Lock() and Unlock() are public methods of Inventory, because the embedded field name Mutex is exported. Code outside the package can lock your inventory. Instead, write:

type Inventory struct {
	mu    sync.Mutex
	stock map[string]int
}

The general rule: embedding brings all methods of the embedded type into your API. If you don’t want to expose them, use a named field.

8.3. Nil Embedded Pointers

Embedding *Address instead of Address means the zero value of the containing struct has a nil pointer. Then c.City will panic. Only embed a pointer when you really need to share an instance or need the method set of *T, and remember to initialize it.

9. When to Use and Not Use Embedding

Use when:

  • You want the outer struct to genuinely expose all the behavior of the embedded type, like embedding http.Handler or io.Reader.
  • You’re writing a decorator or wrapper that only changes a few methods of a large interface.
  • You’re grouping common fields like Audit{CreatedAt, UpdatedAt} into many entities.

Don’t use when:

  • You’re trying to recreate a multi-level inheritance hierarchy with “parent class calling child method”. Use interfaces and explicit dependencies instead (section 4.1).
  • The embedded type has methods you don’t want to expose (section 8.2).
  • You only need one or two methods from that type. A named field with a few hand-written delegating methods will be clearer.

Note: Embedding does not create an is-a relationship. If you need polymorphism, define an interface and have multiple types satisfy it. Don’t rely on “a struct is its embedded type”.

10. Conclusion

Struct embedding is Go’s composition tool, with a bit of syntactic convenience: fields and methods of the embedded type are promoted to the outer struct. It helps you reuse code, satisfy interfaces, and write clean decorators.

However, embedding is not inheritance. There’s no virtual dispatch, the embedded type doesn’t know about the struct containing it, and every promoted method becomes part of your API. Remember three things: use interfaces for behavior that varies, fully initialize embedded interface fields or pointers, and switch to named fields when you don’t want to expose methods.

golangstruct embeddingcompositioninterfaceoop