Syntax at a glance
Types come after names, there are no semicolons, and nothing has to live in a class.
package domain
// CodeAlphabet omits visually ambiguous characters (0/O, 1/I/L).
const CodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
const CodeLength = 6
type RandomCodes struct{}
func (RandomCodes) Generate() string {
b := make([]byte, CodeLength)
rand.Read(b)
for i := range b {
b[i] = CodeAlphabet[int(b[i])%len(CodeAlphabet)]
}
return string(b)
}
:=declares and infers the type, inside functions only.var x Tdeclares a variable holding the zero value ofT. Zero values are meant to be useful: an emptybytes.Bufferorsync.Mutexworks without initialisation.- Exported = starts with a capital letter. That’s the only visibility rule.
Structs + methods instead of classes
Data and behaviour are declared separately. Constructors are plain functions.
type SessionID string // named type: can't pass a QuestionID by mistake
type Session struct {
ID SessionID
State SessionState
CurrentIndex int // -1 in lobby
Participants map[ParticipantID]*Participant
}
// NewSession creates a session in the lobby state.
func NewSession(id SessionID, now time.Time) *Session {
return &Session{ID: id, State: StateLobby, CurrentIndex: -1,
Participants: map[ParticipantID]*Participant{}}
}
func (s *Session) HasAnswered(pid ParticipantID) bool { ... }
Receivers:
| Receiver | Meaning |
|---|---|
(s *Session) |
Pointer: method can mutate, no copy |
(s Session) |
Value: method works on a copy |
Rule of thumb: if any method needs a pointer receiver, give them all pointer receivers.
Enums with iota
type SessionState int
const (
StateLobby SessionState = iota
StateQuestionOpen
StateQuestionClosed
StateFinished
)
func (s SessionState) String() string { ... } // satisfies fmt.Stringer
Interfaces are implicit
A type satisfies an interface by having the methods. No implements.
// package service: the consumer declares what it needs
type Broadcaster interface {
Broadcast(sessionID SessionID, ev Event)
}
// package httpapi: the real WebSocket hub, never mentions Broadcaster
func (h *Hub) Broadcast(id SessionID, ev Event) { ... }
// package service_test: a fake that just remembers
func (r *recorder) Broadcast(_ SessionID, ev Event) {
r.events = append(r.events, ev)
}
Guidelines:
- Declare interfaces where they are used, not next to the implementation.
- Keep them tiny. One or two methods is normal (
io.Reader,io.Writer,fmt.Stringer). - Accept interfaces, return structs.
- Compose bigger interfaces by embedding:
type ReadWriter interface { Reader; Writer }.
Optional compile-time check that a type satisfies an interface:
var _ Broadcaster = (*Hub)(nil)
Embedding instead of inheritance
type BaseRepo struct{ db *sql.DB }
func (r *BaseRepo) Save(o Order) error { return insert(r.db, o) }
func (r *BaseRepo) Audit(m string) { log.Println(m) }
type OrderRepo struct {
*BaseRepo // embedded: Save and Audit are promoted
}
// "override": same method name on the outer type
func (r *OrderRepo) Save(o Order) error {
if err := validate(o); err != nil {
return err
}
if err := r.BaseRepo.Save(o); err != nil { // "super"
return err
}
r.Audit("saved " + o.ID) // promoted from BaseRepo
return nil
}
- The outer type gets the inner type’s methods, but is not a subtype of it.
- The embedded field is named after its type:
r.BaseRepo. - A method on the outer type shadows the promoted one.
The trap: no virtual dispatch through embedding
type Animal struct{}
func (Animal) Name() string { return "animal" }
func (a Animal) Describe() string { return "I am a " + a.Name() }
type Dog struct{ Animal }
func (Dog) Name() string { return "dog" }
Dog{}.Describe() // "I am a animal" (!)
The embedded type never calls back into the outer type, so template-method designs silently break. The idiomatic replacement is a function that takes an interface: shared logic lives in one place, each implementation supplies only what differs.
// Shared behaviour: a test suite every store implementation must pass.
func RunStoreTests(t *testing.T, newStore func(*testing.T) Store) { ... }
// memory_test.go
storetest.RunStoreTests(t, func(*testing.T) Store { return memory.New() })
Polymorphism: interfaces, type switches, generics
Runtime type checks via type assertion. Always use the two-value form; the one-value form panics on mismatch.
p, ok := ctx.Value(ctxParticipant).(Participant) // ok is false if the type does not match
switch v := x.(type) {
case string:
...
case fmt.Stringer:
...
}
Compile-time reuse via type parameters (Go 1.18+):
// clone round-trips a value through JSON so callers can never mutate
// what the store holds.
func clone[T any](v *T) (*T, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
var out T
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
Generics are for algorithms and containers (see slices, maps, cmp in the stdlib). Behaviour still goes through interfaces.
Packages
- A package is a directory. Import cycles are a compile error, so design package boundaries early.
internal/directories can only be imported by code rooted at their parent: a free, compiler-enforced API boundary.- Common layout for a service:
cmd/server/main.go ← wiring only
internal/domain/ ← types + business rules, no I/O
internal/httpapi/ ← transport
internal/store/... ← persistence implementations