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.