05: Go: interfaces, sentinel errors, wrapping, errors.Is¶
These mirror passport-service's driven ports (internal/passport/ports.go) and the error handling in internal/passport/resolve_scan.go. In these services the ports are declared in the core package next to the use cases that consume them. Go interfaces are implicit: a type satisfies an interface just by having the methods. Errors are values. Both ideas are core to the hexagonal style here.
5.1: An interface is a set of method signatures¶
Concept. An interface lists methods. Any type with those methods satisfies it: no implements keyword, no explicit declaration. This is "structural" typing.
Example.
// Condensed from internal/passport/ports.go.
package passport
import "context"
type CatalogRepository interface {
// GetByGTIN returns ErrUnknownProduct on a miss.
GetByGTIN(ctx context.Context, gtin string) (CatalogEntry, error)
}
Note context.Context as the first parameter: the Go convention for carrying cancellation/deadlines through a call chain. Every port method takes it.
Your exercise. Declare an outbound port ScanRecordRepository with two methods: Insert(ctx context.Context, record ScanRecord) error and GetByID(ctx context.Context, scanID string) (ScanRecord, error).
Solution
type ScanRecordRepository interface {
Insert(ctx context.Context, record ScanRecord) error
GetByID(ctx context.Context, scanID string) (ScanRecord, error)
}
5.2: Implicit satisfaction + the compile-time check¶
Concept. Because satisfaction is implicit, the repository uses a compile-time assertion: var _ Interface = Type{}. This line compiles only if Type satisfies Interface; it adds no runtime behavior.
Example.
// From the outbound Personalization adapter.
var _ passport.VerdictGateway = (*Gateway)(nil)
// Compilation fails if *Gateway no longer satisfies the core's driven port.
Your exercise. You have type MemoryCatalog struct{} and the CatalogRepository port from 5.1. (a) Write the method that makes MemoryCatalog satisfy it (return a zero entry and nil). (b) Add the compile-time assertion line.
Solution
type MemoryCatalog struct{}
func (MemoryCatalog) GetByGTIN(ctx context.Context, gtin string) (CatalogEntry, error) {
return CatalogEntry{}, nil
}
var _ CatalogRepository = MemoryCatalog{} // compiles only if the method set matches
The receiver *(MemoryCatalog)* with no name is fine when you don't use it.
5.3: Sentinel errors¶
Concept. A sentinel error is a package-level var Err... = errors.New(...) that callers compare against to branch on a known condition (e.g. "not found"). The repo uses these so the application can react to "unknown product" without parsing strings.
Example.
// Declared in the core package.
package passport
import "errors"
var (
ErrUnknownProduct = errors.New("unknown product")
ErrScanNotFound = errors.New("scan not found")
)
Your exercise. In the passport core package, declare ErrValidation = errors.New("validation") for the HTTP adapter to map to 400. The repository's current declaration lives in internal/passport/inputs.go.
Solution
package passport
import "errors"
var ErrValidation = errors.New("validation")
5.4: Wrapping with %w and checking with errors.Is¶
Concept. fmt.Errorf("context: %w", err) wraps err, adding context while preserving the original for later matching. errors.Is(err, Sentinel) returns true if the sentinel is anywhere in the wrap chain. This is how you add context and stay branch-able.
Example.
// From resolve_scan.go: wrap a sentinel so the adapter can still detect it.
if in.VisitorID == "" {
return ResolvedPassport{}, fmt.Errorf("%w: visitor id is required", ErrValidation)
}
// elsewhere, the HTTP adapter does:
// if errors.Is(err, passport.ErrValidation) { writeStatus(400) }
Your exercise. Write
func loadScan(ctx context.Context, repo ScanRecordRepository, id string) (ScanRecord, error)
that calls repo.GetByID, and on error returns it
wrapped with the context '"load scan
Solution
func loadScan(ctx context.Context, repo ScanRecordRepository, id string) (ScanRecord, error) {
rec, err := repo.GetByID(ctx, id)
if err != nil {
return ScanRecord{}, fmt.Errorf("load scan %s: %w", id, err)
}
return rec, nil
}
// caller:
rec, err := loadScan(ctx, repo, "s1")
if errors.Is(err, ErrScanNotFound) {
// handle 404: works because %w preserved the sentinel through the wrap
}
If you used *%v* instead of *%w*, the message would look the same but
*errors.Is* would return false because the error chain would be broken.
5.5: Branching error handling with switch + errors.Is¶
Concept. When one call can fail in several known ways, a switch over the error (using errors.Is in case guards via switch { case ... }) reads cleaner than nested ifs. The current resolveEntry follows this order.
Example.
// From resolve_scan.go (condensed): catalog hit, known-miss, or unknown failure.
entry, err := uc.Catalog.GetByGTIN(ctx, gtin)
switch {
case err == nil:
return entry, nil
case errors.Is(err, ErrUnknownProduct):
// try the external fallback; otherwise return an explicit miss
return CatalogEntry{}, ErrUnknownProduct
default:
return CatalogEntry{}, err // unexpected: bubble it up unchanged
}
Your exercise. Write classify(err error) string returning "ok" for nil, "not_found" for errors.Is(err, passport.ErrScanNotFound), "bad_input" for errors.Is(err, passport.ErrValidation), and "error" otherwise. Use the conditionless switch { case ... }.
Solution
func classify(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, passport.ErrScanNotFound):
return "not_found"
case errors.Is(err, passport.ErrValidation):
return "bad_input"
default:
return "error"
}
}
An HTTP adapter can use this shape to map application errors to status codes without parsing error
messages.
5.6: The comma, ok and comma, err idioms¶
Concept. Go functions return multiple values. Two idioms dominate: v, ok := (present/absent, e.g. map lookup or type assertion) and v, err := (result or failure). You check the second value immediately.
Example.
// From lookupExternal in resolve_scan.go: note ok=false signals "miss", NOT an error to bubble.
func (uc ResolveScan) lookupExternal(ctx context.Context, gtin string) (CatalogEntry, bool) {
fresh, err := uc.Products.Lookup(ctx, gtin)
if err != nil {
return CatalogEntry{}, false // caller falls back without failing the scan
}
return fresh, true
}
Your exercise. Explain in one or two sentences why this returns (entry, bool) instead of (entry, error). When is each choice right?