06: Go: use cases, injected ports, and degradation

This exercise follows the Go core-package layout. Passport, Fridge, and Shopping List each use one core package under 'internal/'. Entities, use-case structs, inputs, errors, and driven ports live in that package. HTTP handlers call a concrete use case's Execute method; these services do not define a driving-port interface.

The snippets are illustrative and require the surrounding core types to compile.

6.1: A use case receives driven ports

package passport

type ResolveScan struct {
    Catalog  CatalogRepository
    Scans    ScanRecordRepository
    Events   EventPublisher
    Verdicts VerdictGateway
    Journeys JourneySource
    Recipes  RecipeSource
    Products ProductSource
    CatalogCache CatalogWriter
    CatalogTTL time.Duration
    Now      func() time.Time
    NewID    func() string
}

The interfaces are declared in internal/passport/ports.go because the core consumes them. The composition root constructs concrete outbound adapters and supplies them to the use case.

Exercise: identify which dependencies are required to record a scan and which supply optional response sections. Explain why the core owns the interfaces.

Solution Catalog, scan persistence, event publication, clock, and ID generation are required for the current command. The Product source and cache implement community fallback; Verdict, Journey, and Recipe sources produce independently degradable sections. The core owns each interface because it defines the behavior it needs; adapters satisfy that contract.

6.2: Validate composition explicitly

Required dependencies should be validated at construction instead of failing later through a nil interface. Go interfaces also have a typed-nil trap: an interface value can be non-nil while holding a nil pointer.

func NewResolveScan(
    catalog CatalogRepository,
    scans ScanRecordRepository,
    events EventPublisher,
    now func() time.Time,
    newID func() string,
) (ResolveScan, error) {
    if catalog == nil || scans == nil || events == nil || now == nil || newID == nil {
        return ResolveScan{}, errors.New("resolve scan: missing required dependency")
    }
    return ResolveScan{
        Catalog: catalog,
        Scans: scans,
        Events: events,
        Now: now,
        NewID: newID,
    }, nil
}

Exercise: add constructor parameters for the optional sources. Choose either explicit disabled adapter implementations or optional values, and explain how your choice makes wiring errors visible.

Solution Prefer explicit disabled adapters that return a typed unavailable/not-configured error. Every field is then non-nil, the composition is inspectable, and the section mapper can convert the error to a stable failed-state reason code. If optional nils are retained, validate and log the intended disabled configuration in the composition root.

6.3: Orchestration is testable, not pure

An Execute method that reads repositories, writes a scan, and calls external sources performs I/O. Dependency injection makes it deterministic under controlled fakes, but it does not make the function pure.

func (uc ResolveScan) Execute(ctx context.Context, in ResolveScanInput) (ResolvedPassport, error) {
    if in.VisitorID == "" {
        return ResolvedPassport{}, fmt.Errorf("%w: visitor id is required", ErrValidation)
    }
    entry, err := uc.Catalog.GetByGTIN(ctx, in.Item.GTIN)
    if err != nil {
        return ResolvedPassport{}, err
    }
    // Build and persist the ScanRecord, publish ProductScanned, then resolve sections.
    return ResolvedPassport{}, nil
}

Exercise: write a table-driven test for missing Visitor ID, unknown GTIN, and scan-persistence failure. Assert that no integration fact is published when persistence fails.

6.4: Degrade with an explicit section state

An empty value cannot distinguish “the producer returned no Journey” from “the source failed.” Use the response's Loaded/Failed state and stable reason codes.

func resolveJourney(ctx context.Context, src JourneySource, gtin, lot string) TabData[Journey] {
    if src == nil {
        return Failed[Journey]("source_unavailable")
    }
    jctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
    defer cancel()
    journey, err := src.GetJourney(jctx, gtin, lot)
    if err != nil {
        return Failed[Journey]("source_unavailable")
    }
    return Loaded(journey)
}

Exercise: implement the corresponding Recipe helper with distinct no_recipes, provider_quota_exceeded, and source_unavailable codes.

6.5: Publication reliability is a separate concern

Publishing after the database commit prevents an event from announcing a rejected write, but it creates a failure window in which the write commits and the broker publication is lost. Ignoring a publisher error does not solve that problem. Reliable delivery requires an outbox or equivalent durable publication mechanism, retry policy, and idempotent consumers.

Do not invent integration facts in an exercise. Add a fact only after its semantics and JSON Schema exist in the canonical contract catalog.