Practical Introduction
A Go service becomes maintainable when its behavior is easy to see. Not hidden behind inheritance trees. Not spread across utility packages. Not buried in nested conditions. Just clear functions, focused structs, small methods, narrow interfaces, and explicit errors.
Imagine a field service scheduling system. A customer reports an issue, the system creates a work order, a technician is assigned, and another component is notified. The code must answer practical questions:
- Is this work order valid?
- Can a technician be assigned?
- Where should state changes happen?
- What should the service do when the store, notifier, or caller fails?
- How can the behavior be tested without a real database or message system?
Go gives you simple tools for all of this. The skill is using them deliberately.
Context and Scope
The example in this post is a small scheduling service. The same shape appears in many enterprise systems, such as delivery assignment, repair dispatch, ticket routing, or workflow approval.
External request
|
v
Input validation
|
v
Scheduling service
| |
| v
| Notifier
v
Work order store
|
v
Saved assignment or returned error
The actors are:
- A caller, such as an HTTP handler, command-line job, or background worker.
- A scheduling service that owns the use case.
- A store that loads and saves work orders.
- A notifier that publishes the assignment result.
- Domain types such as
WorkOrder,TechnicianID,GeoPoint, andPriority.
The expected output is either a completed assignment or a clear error that explains what failed. The constraints are common in production code: invalid inputs must be rejected early, external work must support cancellation, errors must keep context, and tests should not depend on real infrastructure.
Model Data with Structs, Not Classes
Go does not use classes. A struct groups related fields. Behavior is added separately through functions and methods. This separation keeps data visible and avoids deep inheritance hierarchies.
Start by creating small domain types instead of passing raw strings everywhere. A WorkOrderID and a TechnicianID may both be strings underneath, but they mean different things in the business model.
package scheduling
import (
"errors"
"time"
)
type WorkOrderID string
type TechnicianID string
type Priority uint8
const (
PriorityUnknown Priority = iota
PriorityNormal
PriorityUrgent
)
type GeoPoint struct {
Lat float64
Lon float64
}
type WorkOrder struct {
id WorkOrderID
summary string
site GeoPoint
priority Priority
technician TechnicianID
createdAt time.Time
}
func NewWorkOrder(id WorkOrderID, summary string, site GeoPoint, p Priority) (WorkOrder, error) {
if id == "" {
return WorkOrder{}, errors.New("work order id is required")
}
if summary == "" {
return WorkOrder{}, errors.New("summary is required")
}
if p == PriorityUnknown {
p = PriorityNormal
}
return WorkOrder{
id: id,
summary: summary,
site: site,
priority: p,
createdAt: time.Now(),
}, nil
}
There are a few important choices here.
First, the constructor is just a normal function. Go does not have a special constructor keyword. The function validates required fields and applies defaults before returning a ready-to-use value.
Second, important fields are unexported because they start with lowercase names. That forces callers to use functions and methods instead of writing invalid state directly.
Third, the constructor returns (WorkOrder, error). The caller cannot ignore that creation might fail unless they choose to ignore the error, which should be caught in review or tests.
Use Functions for Plain Behavior
Functions are the simplest way to express behavior in Go. They can return no values, one value, or multiple values. Multiple return values are especially useful when the result and failure state both matter.
Use short, focused functions for logic that does not need to mutate a receiver.
func CanAssign(order WorkOrder, tech TechnicianID) (bool, string) {
if order.id == "" {
return false, "missing work order id"
}
if tech == "" {
return false, "missing technician id"
}
if order.technician != "" {
return false, "work order already assigned"
}
return true, ""
}
func CountRequiredSkills(skills ...string) int {
count := 0
for _, skill := range skills {
if skill != "" {
count++
}
}
return count
}
CanAssign returns a decision and a reason. This is useful when the caller wants to show a validation message without treating every negative decision as a technical error.
CountRequiredSkills uses a variadic parameter, which means callers can pass zero or more strings. Inside the function, skills behaves like a slice. Variadic parameters are useful for readable helper functions, but for larger workflows a normal slice is often clearer.
A practical rule is simple: use functions when behavior does not belong to one specific value, and use methods when the behavior is naturally attached to a type.
Add Methods When Behavior Belongs to a Type
A method is a function with a receiver. The receiver is the value the method is attached to. Go supports value receivers and pointer receivers.
Use a value receiver when the method only reads a small value. Use a pointer receiver when the method mutates state, avoids a large copy, or must handle nil safely.
func (w WorkOrder) ID() WorkOrderID {
return w.id
}
func (w WorkOrder) Assigned() bool {
return w.technician != ""
}
func (w *WorkOrder) AssignTo(tech TechnicianID) error {
if w == nil {
return errors.New("work order is nil")
}
if tech == "" {
return errors.New("technician id is required")
}
if w.technician != "" {
return errors.New("work order already has a technician")
}
w.technician = tech
return nil
}
func (w *WorkOrder) SafeID() WorkOrderID {
if w == nil {
return ""
}
return w.id
}
ID and Assigned read data, so value receivers are fine. AssignTo changes the work order, so it needs a pointer receiver. SafeID shows a useful pattern: a method can be called on a nil pointer receiver as long as it checks for nil before touching fields.
Do not mix receiver styles casually on the same type. It can make interface satisfaction harder to reason about and may introduce unexpected copies. Pick the style that matches the type's behavior and stay consistent.
| Receiver style | Best use | Watch for |
|---|---|---|
| Value receiver | Small read-only values | Copies the receiver |
| Pointer receiver | Mutation, large structs, optional nil handling | Shared state and nil checks |
Reuse with Composition and Embedding
Go avoids inheritance. Instead, you reuse behavior with composition. Composition means one type contains another type. Embedding is a shorthand form where fields and methods of the embedded type are promoted.
type AuditInfo struct {
CreatedBy string
UpdatedBy string
}
func (a AuditInfo) HasAuditTrail() bool {
return a.CreatedBy != "" || a.UpdatedBy != ""
}
type OnSiteWorkOrder struct {
WorkOrder
AuditInfo
Building string
Floor string
}
type RemoteWorkOrder struct {
Order WorkOrder
Link string
}
OnSiteWorkOrder embeds WorkOrder and AuditInfo, so you can access promoted methods such as onSite.Assigned() or onSite.HasAuditTrail(). This is convenient when the embedded behavior should feel like part of the outer type.
RemoteWorkOrder uses a named field instead. Callers must write remote.Order.Assigned(). That is more explicit and often better when you do not want to expose the entire embedded API.
Use embedding carefully. It can reduce boilerplate, but too much embedding makes the domain model unclear. Prefer named fields when ownership should be obvious.
DRY means Don't Repeat Yourself. In Go, DRY does not mean abstract everything early. It means capture real shared knowledge. If every work order type has the same identity and assignment behavior, reuse WorkOrder. If two types only look similar today but have different rules, keep them separate.
Compare Values Deliberately
Go lets you use == only with comparable types. Booleans, numbers, strings, pointers, arrays with comparable elements, and structs with comparable fields can be compared directly. Slices, maps, and functions cannot be compared to another value, except for checking whether they are nil.
This matters when domain types contain slices.
type VisitPlan struct {
ID string
Steps []string
}
func (p VisitPlan) Equal(other VisitPlan) bool {
if p.ID != other.ID {
return false
}
if len(p.Steps) != len(other.Steps) {
return false
}
for i := range p.Steps {
if p.Steps[i] != other.Steps[i] {
return false
}
}
return true
}
VisitPlan cannot be compared with == because it contains a slice. A dedicated Equal method makes the rule explicit and testable. It also lets you decide whether a nil slice and an empty slice should mean the same thing in your business model.
Be careful with interfaces too. An interface value stores both a dynamic type and a dynamic value. A typed nil pointer inside an interface is not the same as a nil interface. This is a common source of confusing error checks, so avoid returning typed nil errors.
Define Interfaces Where Behavior Is Needed
An interface describes behavior through method signatures. A type satisfies an interface automatically when it has the required methods. There is no implements keyword.
The most useful Go interfaces are small and defined by the consumer. That means the package that needs behavior defines the interface it needs.
package scheduling
import (
"context"
"errors"
"fmt"
"time"
)
var ErrWorkOrderNotFound = errors.New("work order not found")
type WorkOrderStore interface {
Find(ctx context.Context, id WorkOrderID) (WorkOrder, error)
Save(ctx context.Context, order WorkOrder) error
}
type AssignmentNotifier interface {
Assigned(ctx context.Context, order WorkOrder) error
}
type Scheduler struct {
store WorkOrderStore
notifier AssignmentNotifier
timeout time.Duration
}
func NewScheduler(store WorkOrderStore, notifier AssignmentNotifier, timeout time.Duration) Scheduler {
return Scheduler{store: store, notifier: notifier, timeout: timeout}
}
func (s Scheduler) Assign(ctx context.Context, id WorkOrderID, tech TechnicianID) error {
if id == "" {
return errors.New("work order id is required")
}
if tech == "" {
return errors.New("technician id is required")
}
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
order, err := s.store.Find(ctx, id)
if err != nil {
return fmt.Errorf("find work order %s: %w", id, err)
}
if err := order.AssignTo(tech); err != nil {
return fmt.Errorf("assign technician %s: %w", tech, err)
}
if err := s.store.Save(ctx, order); err != nil {
return fmt.Errorf("save work order %s: %w", id, err)
}
if err := s.notifier.Assigned(ctx, order); err != nil {
return fmt.Errorf("notify assignment %s: %w", id, err)
}
return nil
}
The scheduler does not know whether the store uses memory, a database, or a remote service. It only needs Find and Save. The notifier could publish to a real message broker in production or record calls in a test.
This is why small interfaces are powerful in enterprise Go. They create seams between components without needing a dependency injection framework.
A useful API rule is: accept interfaces when you need behavior, return concrete types when you create results. Returning a concrete Scheduler or WorkOrder keeps callers aware of what they received. Accepting interfaces lets callers provide different implementations.
Keep Control Flow Shallow
Go conditions require booleans. There are no truthy integers or strings. This makes decisions explicit.
Use guard clauses to reject invalid input early. A guard clause is an early if check that returns before the main path begins. It keeps the normal path left aligned and readable.
You will also use the comma-ok pattern frequently. It appears in map lookups, type assertions, and channel receives.
func priorityLabel(p Priority) string {
switch p {
case PriorityUrgent:
return "urgent"
case PriorityNormal:
return "normal"
default:
return "unknown"
}
}
func cachedOrder(cache map[WorkOrderID]WorkOrder, id WorkOrderID) (WorkOrder, bool) {
order, ok := cache[id]
if !ok {
return WorkOrder{}, false
}
return order, true
}
func normalizeOrderID(raw any) (WorkOrderID, error) {
switch v := raw.(type) {
case WorkOrderID:
return v, nil
case string:
if v == "" {
return "", errors.New("empty work order id")
}
return WorkOrderID(v), nil
default:
return "", fmt.Errorf("unsupported work order id type %T", raw)
}
}
Use type switches at system boundaries where input may be uncertain. Once data enters your domain, prefer strong types so the compiler can help you.
Use switch for clean multi-way decisions. Go does not fall through by default, which avoids a common class of bugs from languages where break is required. The fallthrough keyword exists, but it should be rare because it makes the flow harder to read.
Use Loops with Memory and Determinism in Mind
Go has one loop keyword: for. It supports counted loops, while-style loops, infinite loops, and range loops.
Range loops are clear, but they have details that matter:
- Ranging over a slice gives you a copy of each element.
- Ranging over a map has no stable order.
- Ranging over a string decodes UTF-8 runes, not raw bytes.
- Taking the address of a loop variable can create surprising pointer bugs.
For deterministic output from a map, sort the keys before printing or comparing.
func orderedIDs(orders map[WorkOrderID]WorkOrder) []WorkOrderID {
ids := make([]WorkOrderID, 0, len(orders))
for id := range orders {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
If the slice contains large structs and you only need to inspect or mutate existing elements, iterate by index:
func assignFirstOpen(orders []WorkOrder, tech TechnicianID) error {
for i := range orders {
if orders[i].Assigned() {
continue
}
return orders[i].AssignTo(tech)
}
return errors.New("no open work order found")
}
This avoids copying each element from the slice into a loop variable. It also lets you call pointer receiver methods on the actual element.
For loops that run until canceled, always include a clear exit path.
func runWorker(ctx context.Context, input <-chan WorkOrder) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case order, ok := <-input:
if !ok {
return nil
}
_ = order
}
}
}
An infinite loop without cancellation is a future incident. A worker must know when to stop.
Treat Errors as Part of the Design
Go failure handling is explicit. A function returns an error value, and the caller decides what to do. That may feel repetitive at first, but it makes production behavior easier to review.
Use clear lowercase error messages without trailing punctuation. Add context when returning an error from a lower layer. Use %w with fmt.Errorf when the caller may need to inspect the original error with errors.Is or errors.As.
Sentinel errors are named shared values for simple known conditions. Typed errors carry structured details.
type TechnicianLoadError struct {
Technician TechnicianID
OpenJobs int
Limit int
}
func (e *TechnicianLoadError) Error() string {
return fmt.Sprintf("technician %s has %d open jobs, limit is %d", e.Technician, e.OpenJobs, e.Limit)
}
func CheckTechnicianLoad(id TechnicianID, openJobs int, limit int) error {
if openJobs > limit {
return &TechnicianLoadError{
Technician: id,
OpenJobs: openJobs,
Limit: limit,
}
}
return nil
}
Callers can inspect this error without parsing the message:
err := CheckTechnicianLoad("tech-17", 9, 6)
var loadErr *TechnicianLoadError
if errors.As(err, &loadErr) {
fmt.Println(loadErr.Technician, loadErr.OpenJobs)
}
Use sentinel errors when the only thing that matters is the condition, such as ErrWorkOrderNotFound. Use typed errors when the caller needs fields like an ID, limit, state, or count.
Avoid logging and returning the same error in library code. Return the error with context. Log once at the application edge, such as the HTTP handler, CLI command, or worker supervisor. Double logging makes incidents harder to read because the same failure appears multiple times with different context.
Clean Up Resources with Defer
Use defer when a resource must be cleaned up before the function returns. This is common with files, connections, locks, and request-scoped cleanup.
When cleanup itself can fail, preserve both the main error and the cleanup error.
func closeAll(resources ...io.Closer) error {
var combined error
for _, r := range resources {
if r == nil {
continue
}
if err := r.Close(); err != nil {
combined = errors.Join(combined, fmt.Errorf("close resource: %w", err))
}
}
return combined
}
Do not use panic for ordinary business problems. A missing work order, invalid technician, timeout, or rejected assignment should be an error value. Panic is for impossible states or programmer mistakes. At application edges, recovering from a panic can keep the process alive, but the recovered failure should still be logged and fixed.
Testing the Design
Small interfaces make testing simple. You can replace the store and notifier with tiny fakes.
package scheduling
import (
"context"
"testing"
"time"
)
type memoryStore struct {
item WorkOrder
saved bool
}
func (m *memoryStore) Find(ctx context.Context, id WorkOrderID) (WorkOrder, error) {
if m.item.ID() != id {
return WorkOrder{}, ErrWorkOrderNotFound
}
return m.item, nil
}
func (m *memoryStore) Save(ctx context.Context, order WorkOrder) error {
m.item = order
m.saved = true
return nil
}
type recordingNotifier struct {
sent bool
}
func (n *recordingNotifier) Assigned(ctx context.Context, order WorkOrder) error {
n.sent = true
return nil
}
func TestSchedulerAssign(t *testing.T) {
order, err := NewWorkOrder("wo-100", "replace door sensor", GeoPoint{Lat: 1, Lon: 2}, PriorityUrgent)
if err != nil {
t.Fatalf("create work order: %v", err)
}
store := &memoryStore{item: order}
notifier := &recordingNotifier{}
scheduler := NewScheduler(store, notifier, time.Second)
if err := scheduler.Assign(context.Background(), "wo-100", "tech-9"); err != nil {
t.Fatalf("assign work order: %v", err)
}
if !store.saved {
t.Fatal("expected work order to be saved")
}
if !notifier.sent {
t.Fatal("expected assignment notification")
}
}
Run the tests with the standard toolchain:
go test ./...
go test -run TestSchedulerAssign ./...
go test -race ./...
The design is testable because the scheduler depends on behavior, not concrete infrastructure. No database is required. No real notification system is required. The test checks the use case directly.
Common Mistakes to Watch For
- Putting real business logic in
main. Keepmainthin and move behavior into packages. - Creating large provider-owned interfaces before any consumer needs them.
- Returning interfaces when a concrete result would be clearer.
- Using pointer receivers everywhere by habit.
- Forgetting nil checks in methods that accept nil pointer receivers.
- Comparing structs with slice or map fields using
==, which will not compile. - Depending on map iteration order in tests or logs.
- Taking the address of a range loop variable instead of the slice element.
- Nesting conditions deeply instead of using guard clauses.
- Logging an error in a library and also returning it to be logged again.
- Using panic for normal validation or infrastructure failures.
- Starting long-running loops without context cancellation.
Checklist
Before merging Go code that models service behavior, check the following:
- Does each function do one clear job?
- Do constructors validate required fields and apply defaults in one place?
- Are structs used for data and methods used for behavior that belongs to that data?
- Are pointer receivers used only for mutation, large values, nil handling, or consistency?
- Is shared behavior modeled with composition instead of copy-paste?
- Are interfaces small and defined where they are consumed?
- Are external calls using
context.Contextwhen they may block or time out? - Are error messages lowercase, clear, and free of punctuation noise?
- Are errors wrapped with useful operation context?
- Are sentinel errors and typed errors used for the right level of detail?
- Are loops deterministic when output order matters?
- Are tests using fakes or stubs instead of real infrastructure when possible?
Conclusion
Go's behavior model is intentionally small. Functions express reusable actions. Structs describe data. Methods attach meaningful operations to types. Interfaces describe only the behavior a consumer needs. Conditions, loops, and explicit error values keep the flow visible.
This simplicity is practical, not decorative. It lets developers review code quickly, test use cases without heavy frameworks, and evolve services without turning every change into a refactor. When behavior is modeled clearly, the system is easier to operate, easier to debug, and easier for the next developer to understand.