package main import ( "fmt" "reflect" "sync" ) // mutation_registry.go // // Mutation Registry Infrastructure // -------------------------------- // This file introduces a generic registry for cart mutations that: // // 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply. // 2. Enforces (at registration time) that every mutation handler has the correct // signature: func(*CartGrain, *T) error // 3. Optionally auto-updates cart totals after a mutation if flagged. // 4. Provides a single authoritative list of registered mutations for // introspection / coverage testing. // 5. Allows incremental migration: you can first register new mutations here, // and later prune the legacy switch cases. // // Usage Pattern // ------------- // // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto) // // Regenerate protobufs. // // // In an init() (ideally in a small file like mutations_apply_coupon.go) // func init() { // RegisterMutation[*messages.ApplyCoupon]( // "ApplyCoupon", // func(g *CartGrain, m *messages.ApplyCoupon) error { // // domain logic ... // discount := int64(5000) // if g.TotalPrice < discount { // discount = g.TotalPrice // } // g.TotalDiscount += discount // g.TotalPrice -= discount // return nil // }, // WithTotals(), // we changed price-related fields; recalc totals // ) // } // // // To invoke dynamically (alternative to the current switch): // if updated, err := ApplyRegistered(grain, incomingMessage); err == nil { // grain = updated // } else if errors.Is(err, ErrMutationNotRegistered) { // // fallback to legacy switch logic // } // // Migration Strategy // ------------------ // 1. For each existing mutation handled in CartGrain.Apply, add a registry // registration with equivalent logic. // 2. Add a test that enumerates all *expected* mutation proto types and asserts // they are present in RegisteredMutationTypes(). // 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call // to ApplyRegistered (and optionally keep a minimal default to produce an // "unsupported mutation" error). // // Thread Safety // ------------- // Registration is typically done at init() time; a RWMutex provides safety // should late dynamic registration ever be introduced. // // Auto Totals // ----------- // Many mutations require recomputing totals. To avoid forgetting this, pass // WithTotals() when registering. This will invoke grain.UpdateTotals() after // the handler returns successfully. // // Error Semantics // --------------- // - If a handler returns an error, totals are NOT recalculated (even if // WithTotals() was specified). // - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type // is absent. // // Extensibility // ------------- // It is straightforward to add options like audit hooks, metrics wrappers, // or optimistic concurrency guards by extending MutationOption. // // NOTE: Generics require Go 1.18+. If constrained to earlier Go versions, // replace the generic registration with a non-generic RegisterMutationType // that accepts reflect.Type and an adapter function. // // --------------------------------------------------------------------------- var ( mutationRegistryMu sync.RWMutex mutationRegistry = make(map[reflect.Type]*registeredMutation) // ErrMutationNotRegistered is returned when no handler exists for a given mutation type. ErrMutationNotRegistered = fmt.Errorf("mutation not registered") ) // MutationOption configures additional behavior for a registered mutation. type MutationOption func(*mutationOptions) // mutationOptions holds flags adjustable per registration. type mutationOptions struct { updateTotals bool } // WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler. func WithTotals() MutationOption { return func(o *mutationOptions) { o.updateTotals = true } } // registeredMutation stores metadata + the execution closure. type registeredMutation struct { name string handler func(*CartGrain, interface{}) error updateTotals bool msgType reflect.Type } // RegisterMutation registers a mutation handler for a specific message type T. // // Parameters: // // name - a human-readable identifier (used for diagnostics / coverage tests). // handler - business logic operating on the cart grain & strongly typed message. // options - optional behavior flags (e.g., WithTotals()). // // Panics if: // - name is empty // - handler is nil // - duplicate registration for the same message type T // // Typical call is placed in an init() function. func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) { if name == "" { panic("RegisterMutation: name is required") } if handler == nil { panic("RegisterMutation: handler is nil") } // Derive the reflect.Type for *T then its Elem (T) for mapping. var zero *T rtPtr := reflect.TypeOf(zero) if rtPtr.Kind() != reflect.Ptr { panic("RegisterMutation: expected pointer type for generic parameter") } rt := rtPtr.Elem() opts := mutationOptions{} for _, opt := range options { opt(&opts) } wrapped := func(g *CartGrain, m interface{}) error { typed, ok := m.(*T) if !ok { return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name()) } return handler(g, typed) } mutationRegistryMu.Lock() defer mutationRegistryMu.Unlock() if _, exists := mutationRegistry[rt]; exists { panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String())) } mutationRegistry[rt] = ®isteredMutation{ name: name, handler: wrapped, updateTotals: opts.updateTotals, msgType: rt, } } // ApplyRegistered attempts to apply a registered mutation. // Returns updated grain if successful. // // If the mutation is not registered, returns (nil, ErrMutationNotRegistered). func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) { if grain == nil { return nil, fmt.Errorf("nil grain") } if msg == nil { return nil, fmt.Errorf("nil mutation message") } rt := indirectType(reflect.TypeOf(msg)) mutationRegistryMu.RLock() entry, ok := mutationRegistry[rt] mutationRegistryMu.RUnlock() if !ok { return nil, ErrMutationNotRegistered } if err := entry.handler(grain, msg); err != nil { return nil, err } if entry.updateTotals { grain.UpdateTotals() } return grain, nil } // RegisteredMutations returns metadata for all registered mutations (snapshot). func RegisteredMutations() []string { mutationRegistryMu.RLock() defer mutationRegistryMu.RUnlock() out := make([]string, 0, len(mutationRegistry)) for _, entry := range mutationRegistry { out = append(out, entry.name) } return out } // RegisteredMutationTypes returns the reflect.Type list of all registered messages. // Useful for coverage tests ensuring expected set matches actual set. func RegisteredMutationTypes() []reflect.Type { mutationRegistryMu.RLock() defer mutationRegistryMu.RUnlock() out := make([]reflect.Type, 0, len(mutationRegistry)) for t := range mutationRegistry { out = append(out, t) } return out } // MustAssertMutationCoverage can be called at startup to ensure every expected // mutation type has been registered. It panics with a descriptive message if any // are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...) func MustAssertMutationCoverage(expected []interface{}) { mutationRegistryMu.RLock() defer mutationRegistryMu.RUnlock() missing := make([]string, 0) for _, ex := range expected { if ex == nil { continue } t := indirectType(reflect.TypeOf(ex)) if _, ok := mutationRegistry[t]; !ok { missing = append(missing, t.String()) } } if len(missing) > 0 { panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing)) } } // indirectType returns the element type if given a pointer; otherwise the type itself. func indirectType(t reflect.Type) reflect.Type { for t.Kind() == reflect.Ptr { t = t.Elem() } return t } /* Integration Guide ----------------- 1. Register all existing mutations: func init() { RegisterMutation[*messages.AddItem]("AddItem", func(g *CartGrain, m *messages.AddItem) error { // (port logic from existing switch branch) // ... return nil }, WithTotals(), ) // ... repeat for others } 2. In CartGrain.Apply (early in the method) add: if updated, err := ApplyRegistered(c, content); err == nil { return updated, nil } else if err != ErrMutationNotRegistered { return nil, err } // existing switch fallback below 3. Once all mutations are registered, remove the legacy switch cases and leave a single ErrMutationNotRegistered path for unknown types. 4. Add a coverage test (see docs for example; removed from source for clarity). 5. (Optional) Add metrics / tracing wrappers for handlers. */