04: Go: structs, methods, receivers, zero values¶
These mirror the domain entities in services/passport-service/internal/passport/. Go has no classes: you compose behavior from structs (data) and methods (functions with a receiver). The exercises build up the domain-entity style used in the Go services.
4.1: A struct: named fields with types¶
Concept. A struct groups named, typed fields. Field names starting with an uppercase letter are exported (visible outside the package); lowercase are package-private. Tags like json:"..." are out of scope here.
Example.
// Modelled on Verdict / VerdictReason in passport-service's internal/passport core.
package domain
type Verdict struct {
Grade string
Reasons []VerdictReason
GoalFit string
RuleVersion string
}
type VerdictReason struct {
Type string
MatchedCode string
Severity string
}
Your exercise. Write a struct ScanRecord with fields: ScanID (string), VisitorID (string), BrandID (string), and ScannedAt (time.Time). Make them all exported.
Solution
import "time"
type ScanRecord struct {
ScanID string
VisitorID string
BrandID string
ScannedAt time.Time
}
4.2: Constructing a struct; the zero value¶
Concept. T{} is the zero value: every field set to its type's zero ("" for string, 0 for numbers, nil for slices/maps/pointers/interfaces). Go has no null for these: a struct is always fully formed. You often return T{} alongside an error.
Example.
v := Verdict{Grade: "Good"} // Reasons is nil, GoalFit is "", RuleVersion is ""
empty := Verdict{} // every field zero
_ = empty.Reasons == nil // true: a nil slice is the zero value, and is safe to range over
Your exercise. Given the ScanRecord above, (a) construct one with only ScanID set, and (b) write what ScannedAt and BrandID equal. Then range over a nil []VerdictReason and state how many iterations run.
Solution
r := ScanRecord{ScanID: "scan-1"}
// r.BrandID == "" (zero string)
// r.ScannedAt == time.Time{} (the zero time, not nil)
var rs []VerdictReason // nil slice
for range rs { // runs ZERO times: ranging a nil slice is safe
}
Key Go fact: a nil slice/map is usable for reading and ranging; you only need to
make/allocate before *writing* to a map.
4.3: Methods with a value receiver¶
Concept. A method is a function with a receiver written before the name: func (r ScanRecord) X(). A value receiver (r ScanRecord) gets a copy: use it for read-only behavior. Methods live on the type, in the same package.
Example.
// A read-only method computing something from the struct.
func (r ScanRecord) IsBranded() bool {
return r.BrandID != ""
}
Your exercise. Add a method Keyword() on a struct CatalogEntry { Name string; Brand string } that returns Brand + " " + Name when both are set, else whichever is non-empty, else "". (This mirrors RecipeKeyword in the repository.)
Solution
type CatalogEntry struct {
Name string
Brand string
}
func (e CatalogEntry) Keyword() string {
switch {
case e.Brand != "" && e.Name != "":
return e.Brand + " " + e.Name
case e.Brand != "":
return e.Brand
default:
return e.Name
}
}
A *switch* with no condition (*switch {*) is Go's clean if/else-if ladder.
4.4: Value vs pointer receiver¶
Concept. A pointer receiver '(r *ScanRecord)' can mutate the original and avoids copying a large struct. Use a pointer receiver when the method changes the struct or the struct is big; a value receiver otherwise. Be consistent per type.
Example.
func (r *ScanRecord) AssignBrand(id string) {
r.BrandID = id // mutates the caller's struct, because r is a pointer
}
rec := ScanRecord{ScanID: "s1"}
rec.AssignBrand("brand-9") // Go auto-takes &rec; rec.BrandID is now "brand-9"
Your exercise. (a) Write func (r ScanRecord) WithBrand(id string) ScanRecord that returns a copy with the brand set, leaving the original unchanged (the immutable style). (b) In one sentence, say when you'd prefer this over the pointer-mutating AssignBrand.
Solution
func (r ScanRecord) WithBrand(id string) ScanRecord {
r.BrandID = id // r is a copy; mutating it is local
return r // caller's original is untouched
}
(b) Prefer the copy-returning form when you want immutability/value semantics (no
caller surprised by mutation); prefer the pointer form when the struct is large or
the method's job *is* to update in place.
4.5: Slices and maps¶
Concept. []T is a growable list; map[K]V is a hash map. append grows a slice (reassign the result). Reading a missing map key returns the value's zero; the two-value form v, ok := m[k] tells you whether it was present.
Example.
reasons := []VerdictReason{}
reasons = append(reasons, VerdictReason{Type: "allergen", MatchedCode: "milk"})
thresholds := map[string]int{"sugar": 5}
v, ok := thresholds["salt"] // v == 0, ok == false (missing)
s := thresholds["sugar"] // 5
Your exercise. Write func firedCodes(checks map[string]int, nutrition map[string]int) []string returning the keys of checks whose threshold is '<=' the matching nutrition value (missing nutrition reads 0). Append matches to a result slice.
Solution
func firedCodes(checks map[string]int, nutrition map[string]int) []string {
var fired []string // nil slice; append works fine
for code, threshold := range checks {
if nutrition[code] >= threshold { // missing key -> 0, which is the desired default
fired = append(fired, code)
}
}
sort.Strings(fired) // map iteration order is unspecified
return fired
}
This solution requires *import "sort"* and sorts the output because Go does not define
map iteration order. *nutrition[code]* returning 0 for an absent key is acceptable only if missing
nutrition semantically means zero; otherwise use the comma-ok form and model missing data.
4.6: Constants and typed string codes¶
Concept. const blocks define compile-time constants. A named string type makes invalid code mixing harder than untyped string constants alone.
Example.
// From domain/verdict.go.
type VerdictReasonCode string
const (
VerdictReasonUnauthenticated VerdictReasonCode = "unauthenticated"
VerdictReasonNoProfile VerdictReasonCode = "no_profile"
VerdictReasonUnavailable VerdictReasonCode = "verdict_unavailable"
)
Your exercise. Define a const block of freshness codes FreshnessFresh, FreshnessExpiring, FreshnessExpired with the string values "fresh", "expiring", "expired". Then write func freshness(days int) string returning the right constant ('<= 0' expired, '<= 5' expiring, else fresh).
Solution
const (
FreshnessFresh = "fresh"
FreshnessExpiring = "expiring"
FreshnessExpired = "expired"
)
func freshness(days int) string {
switch {
case days <= 0:
return FreshnessExpired
case days <= 5:
return FreshnessExpiring
default:
return FreshnessFresh
}
}
Same logic as TS *computeFreshnessStatus* (exercise 02.1): compare the two
languages side by side.