Structs, embedding, and interfaces nobody declares
A Go type is data plus methods. There is no class, no constructor, no protected, no vtable you can inspect,
no extends, no super. Everything you built with inheritance you now build with embedding and interfaces.
Struct + methods vs class
package billing
import "fmt"
type Invoice struct {
ID string
Amount int64 // cents; Go has no BigDecimal in the stdlib — you choose the representation
}
// Value receiver: works on a copy. Use for small, immutable-ish types.
func (i Invoice) Display() string {
return fmt.Sprintf("%s: %d.%02d", i.ID, i.Amount/100, i.Amount%100)
}
// Pointer receiver: can mutate. Methods are just functions with a receiver.
func (i *Invoice) ApplyDiscount(pct int) {
i.Amount -= i.Amount * int64(pct) / 100
}
// No constructor keyword. A plain function is the idiom, and it validates.
func NewInvoice(id string, amount int64) (*Invoice, error) {
if amount < 0 {
return nil, fmt.Errorf("negative amount %d", amount)
}
return &Invoice{ID: id, Amount: amount}, nil
}
public class Invoice {
private final String id;
private long amount; // cents
public Invoice(String id, long amount) {
if (amount < 0) throw new IllegalArgumentException("negative amount " + amount);
this.id = id;
this.amount = amount;
}
public String display() { return "%s: %.2f".formatted(id, amount / 100.0); }
public void applyDiscount(int pct) { amount -= amount * pct / 100; }
}
// closest analogue: a plain object + free functions. Go deliberately has no `class` culture.
export type Invoice = { id: string; amount: number };
export const newInvoice = (id: string, amount: number): Invoice => {
if (amount < 0) throw new Error(`negative amount ${amount}`);
return { id, amount };
};
export const applyDiscount = (i: Invoice, pct: number): Invoice => ({
...i,
amount: i.amount - Math.trunc((i.amount * pct) / 100),
});
Note what disappeared: this, field visibility keywords, a constructor, and inheritance. Note what appeared:
an error return instead of throw (module 03 is entirely about why).
Interfaces: the part that changes how you design
In Java, implements is a declaration at the type. In Go, an interface is satisfied by having the methods —
the implementation does not know the interface exists.
package main
import "fmt"
type Notifier interface {
Notify(msg string) error
}
type EmailClient struct{ from string }
func (e EmailClient) Notify(msg string) error { // no "implements" anywhere
fmt.Println("email:", msg)
return nil
}
type AuditLog struct{}
func (AuditLog) Notify(msg string) error { fmt.Println("audit:", msg); return nil }
func Alert(n Notifier, msg string) error { return n.Notify(msg) }
func main() {
Alert(EmailClient{from: "billing@example.com"}, "invoice sent")
Alert(AuditLog{}, "invoice sent")
}
interface Notifier { void notify(String msg); }
class EmailClient implements Notifier { // explicit, nominal
public void notify(String msg) { System.out.println("email: " + msg); }
}
class AuditLog implements Notifier {
public void notify(String msg) { System.out.println("audit: " + msg); }
}
// TypeScript is structurally typed too — this is why TS maps to Go better than Java does.
interface Notifier { notify(msg: string): void }
const email = (from: string): Notifier => ({ notify: (m) => console.log('email:', m) });
const audit: Notifier = { notify: (m) => console.log('audit:', m) };
Three consequences you should adopt immediately:
- Declare interfaces where you consume them, not where you implement them.
billingdoes not exportNotifier; the caller's package declares the 1–2 methods it needs. A Java habit that bugs Go reviewers is an interface with 12 methods in aservicepackage that every impl must satisfy. - Small is normal.
io.Readeris one method. If your interface has more than three methods, ask whether it is two interfaces. - Return structs, accept interfaces.
func New() *Storeandfunc Serve(db DB). It keeps the dependency surface minimal.
Embedding is not inheritance
package main
import "fmt"
type Base struct{ ID string }
func (b Base) Describe() string { return "id=" + b.ID }
type Customer struct {
Base // embedded: promoted methods, but NOT a subtype
Name string
}
func main() {
c := Customer{Base: Base{ID: "c-42"}, Name: "ACME"}
fmt.Println(c.Describe()) // promoted from Base
fmt.Println(c.ID, c.Name)
// You cannot pass c where a Base is expected — no subtyping, no override-super.
}
If you want the Java behaviour, you write it explicitly: a field, and a delegating method. Being forced to write the delegation is the feature — deep hierarchies are how Java codebases rot, and Go simply does not let you build them.
Trap: embedding
sync.Mutexin a struct promotesLock/Unlockto your public API, and it is a data race waiting to happen once the struct is copied. Embed deliberately; prefer a named field.
Checkpoint
- Define
type Money int64and give itAdd(Money) MoneyandString() string. Note thatString()makes it satisfyfmt.Stringerwithout importing anything — that is implicit interface satisfaction in the wild. - Take your invoice service from M0.02. Change the handler to accept an
InvoiceStoreinterface with two methods, declared in the HTTP package, implemented by an in-memory struct in astorepackage. No mock library. - Add a
Customerthat embedsInvoice's type and overrides nothing, then try passingCustomertofunc f(i Invoice). Read the compiler error — it will teach you the difference between subtyping and embedding in one line.