Architecture
This document explains how slush implements type-safe service storage and retrieval. It's intended for contributors and users who want to understand the internals.
Component Overview
┌─────────────────────────────────────────────────────────────┐
│ Public API │
│ Register[T]() Use[T]() Services() Reset() Unregister[] │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Registry │
│ map[reflect.Type]service (interface) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ handle[T] (generic) │
│ impl T │ guards []Guard │ interfaceFQDN │ implFQDN │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ service (interface) │
│ fqdns() │ guards() │ guardCount() │
└─────────────────────────────────────────────────────────────┘
Type Erasure Pattern
The core challenge: Go maps require a uniform value type, but we want to store handle[Database], handle[Mailer], etc. in the same map.
Solution: Interface-based type erasure
// Non-generic interface for storage
type service interface {
fqdns() (iface, impl string)
guards() []Guard
guardCount() int
}
// Generic implementation
type handle[T any] struct {
impl T
g []Guard
interfaceFQDN string
implFQDN string
}
// handle[T] satisfies service
func (h *handle[T]) fqdns() (string, string) { ... }
func (h *handle[T]) guards() []Guard { ... }
func (h *handle[T]) guardCount() int { ... }
// Registry stores the interface
var services = map[reflect.Type]service
On registration, we create a *handle[T] and store it as service.
On retrieval, we type-assert back to *handle[T]:
func Use[T any](ctx context.Context) (T, error) {
svc := services[reflect.TypeFor[T]()]
h := svc.(*handle[T]) // Safe: keyed by same type
return h.impl, nil
}
This preserves type safety—impl is stored as T, not any.
Registration Flow
Register[Database](db)
│
▼
┌───────────────────────────────────┐
│ 1. Compute FQDNs │
│ - interfaceFQDN from TypeFor[T]│
│ - implFQDN from TypeOf(impl) │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 2. Create handle[T] │
│ - Store impl T │
│ - Initialize empty guards │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 3. Store in registry │
│ - Lock mutex │
│ - services[key] = handle │
│ - Unlock mutex │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 4. Emit capitan event │
│ - SignalRegistered │
│ - interface + impl FQDNs │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 5. Return Handle[T] │
│ - Wraps *handle[T] │
│ - Exposes .Guard() method │
└───────────────────────────────────┘
Retrieval Flow
Use[Database](ctx)
│
▼
┌───────────────────────────────────┐
│ 1. Lookup in registry │
│ - RLock mutex │
│ - Get services[TypeFor[T]()] │
│ - RUnlock mutex │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 2. Check existence │
│ - If not found: emit event, │
│ return ErrNotFound │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 3. Type-assert to *handle[T] │
│ - Safe because key matches │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 4. Run guards │
│ - For each guard in order: │
│ - Call guard(ctx) │
│ - If error: emit event, │
│ return ErrAccessDenied │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 5. Emit success event │
│ - SignalAccessed │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 6. Return h.impl │
│ - Type T, no casting needed │
└───────────────────────────────────┘
Design Q&A
Why a global registry instead of explicit dependency injection?
Go generics can't have type parameters on methods. Register[T] must be a package-level function. A method like registry.Register[T]() isn't valid Go.
Why reflect.Type as the map key?
It's the only way to differentiate handle[Database] from handle[Mailer] at runtime. reflect.TypeFor[T]() is computed at compile time, so there's no runtime reflection cost for the key.
Why pre-compute FQDNs at registration?
To avoid reflection in the hot path (Use). FQDNs are computed once and stored as strings. Use only calls pre-computed string accessors.
Why the service interface?
To enable Services() enumeration without knowing T. The interface provides accessors for metadata that don't require the type parameter.
Performance Characteristics
| Operation | Complexity | Notes |
|---|---|---|
| Register | O(1) | Map insert + one reflection call |
| Use (no guards) | O(1) | Map lookup + type assertion |
| Use (with guards) | O(n) | n = number of guards |
| Services | O(n) | n = number of registered services |
| Reset | O(1) | Map reallocation |
Concurrency: All operations are protected by sync.RWMutex. Multiple Use calls can run concurrently; Register and Reset acquire exclusive locks.
Next Steps
- Guards Guide — Writing efficient guards
- API Reference — Complete function documentation
- capitan Integration — Event emission details