Controllable Interface Narrowing Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 21 March 2026
- Status
- DRAFT
Overview¶
The Controllable interface in pkg/controls/controls.go has 18 methods spanning runtime control, state access, configuration, and channel management. This violates the Go proverb "the bigger the interface, the weaker the abstraction" โ consumers that only need to start/stop a service must depend on the full 18-method contract.
Splitting into focused interfaces lets consumers declare minimal dependencies while the Controller struct continues to implement everything.
Design Decisions¶
Composition over replacement: The existing Controllable interface becomes a composed interface embedding all narrow interfaces. Existing code continues to compile without changes.
Role-based split: Interfaces are grouped by responsibility, not by getter/setter pairs. A consumer that needs to run services gets Runner; one that needs to configure channels gets Configurable.
ControllerOpt functions accept Configurable: Options like WithoutSignals, WithShutdownTimeout, and WithLogger only need setter methods. Their parameter type narrows from Controllable to Configurable.
Public API Changes¶
New Interfaces¶
// Runner provides service lifecycle operations.
type Runner interface {
Start()
Stop()
IsRunning() bool
IsStopped() bool
IsStopping() bool
Register(id string, opts ...ServiceOption)
}
// StateAccessor provides read access to controller state and context.
type StateAccessor interface {
GetState() State
SetState(state State)
GetContext() context.Context
GetLogger() *slog.Logger
}
// Configurable provides controller configuration setters.
type Configurable interface {
SetErrorsChannel(errs chan error)
SetMessageChannel(control chan Message)
SetSignalsChannel(sigs chan os.Signal)
SetHealthChannel(health chan HealthMessage)
SetWaitGroup(wg *sync.WaitGroup)
SetShutdownTimeout(d time.Duration)
SetLogger(logger *slog.Logger)
}
// ChannelProvider provides access to controller channels.
type ChannelProvider interface {
Messages() chan Message
Health() chan HealthMessage
Errors() chan error
Signals() chan os.Signal
}
Modified: Controllable¶
// Controllable is the full controller interface, composed of all role-based interfaces.
// Prefer using the narrower interfaces (Runner, Configurable, etc.) where possible.
type Controllable interface {
Runner
StateAccessor
Configurable
ChannelProvider
}
Modified: ControllerOpt¶
Internal Implementation¶
Compile-Time Satisfaction Checks¶
Add to controller.go:
var (
_ Runner = (*Controller)(nil)
_ StateAccessor = (*Controller)(nil)
_ Configurable = (*Controller)(nil)
_ ChannelProvider = (*Controller)(nil)
_ Controllable = (*Controller)(nil)
)
ControllerOpt Migration¶
// Before:
func WithoutSignals() ControllerOpt {
return func(c Controllable) {
c.SetSignalsChannel(nil)
}
}
// After:
func WithoutSignals() ControllerOpt {
return func(c Configurable) {
c.SetSignalsChannel(nil)
}
}
Same for WithShutdownTimeout and WithLogger.
NewController Update¶
func NewController(ctx context.Context, opts ...ControllerOpt) *Controller {
// ... create controller ...
for _, opt := range opts {
opt(c) // Controller satisfies Configurable
}
return c
}
Project Structure¶
pkg/controls/
โโโ controls.go โ MODIFIED: new interfaces, Controllable becomes composed
โโโ controller.go โ MODIFIED: compile-time checks, ControllerOpt type change
โโโ controls_test.go โ MODIFIED: add interface satisfaction tests
โโโ services.go โ UNCHANGED
Testing Strategy¶
| Test | Scenario |
|---|---|
TestController_SatisfiesRunner |
Compile-time: var _ Runner = (*Controller)(nil) |
TestController_SatisfiesStateAccessor |
Compile-time check |
TestController_SatisfiesConfigurable |
Compile-time check |
TestController_SatisfiesChannelProvider |
Compile-time check |
TestController_SatisfiesControllable |
Compile-time check (existing) |
TestControllerOpt_WithConfigurable |
WithoutSignals() works with Configurable parameter |
| Existing tests | All existing controls_test.go tests pass unchanged |
Coverage¶
- Target: 90%+ for
pkg/controls/.
Linting¶
golangci-lint run --fixmust pass.- No new
nolintdirectives. - The
interfacebloatlinter (if enabled) will no longer flagControllablesince the narrow interfaces are small.
Documentation¶
- Godoc for each new interface explaining its role and when to use it.
- Add guidance to
Controllablegodoc: "Prefer the narrower interfaces where possible." - Update
docs/components/controls.mdwith the new interface hierarchy and usage guidance.
Backwards Compatibility¶
- No breaking changes for
Controllableconsumers: The composed interface has the exact same method set. - Minor breaking change for
ControllerOpt: Parameter type changes fromControllabletoConfigurable. Any external code defining customControllerOptfunctions that call non-setter methods will need updating. This is expected to be rare. - Mock regeneration: Mocks for
Controllablewill need regeneration, but the mock implementation is unchanged.
Future Considerations¶
- gRPC and HTTP servers:
pkg/controls/grpcandpkg/controls/httpcould acceptRunnerinstead ofControllableif they only need lifecycle methods. - Event-driven state: If state transitions become event-driven,
StateAccessoris the natural interface to extend.
Implementation Phases¶
Phase 1 โ Define Interfaces¶
- Add
Runner,StateAccessor,Configurable,ChannelProvidertocontrols.go - Redefine
Controllableas composed interface - Add compile-time satisfaction checks
Phase 2 โ Narrow ControllerOpt¶
- Change
ControllerOpttype tofunc(Configurable) - Update
WithoutSignals,WithShutdownTimeout,WithLogger - Verify
NewControllerstill compiles
Phase 3 โ Tests & Docs¶
- Add interface satisfaction tests
- Run full test suite
- Update documentation