Air — Interfaces#
Interface catalog#
exiter#
- Package:
github.com/air-verse/air/runner - File:
runner/exiter.go:5 - Methods:
Exit(code int) - Purpose: Wraps
os.Exitso that tests can intercept process termination without killing the test runner. TheEnginestruct holds anexiterfield instead of callingos.Exitdirectly. - Implementations:
defaultExiter(production) — delegates toos.Exit(code)testExiter(inrunner/engine_test.go:1225) — records the exit code for assertion; does not exit
- Design quality: Minimal and well-segregated. A perfect single-responsibility interface. Follows ISP: it exposes exactly one method, which is all the caller ever needs to vary.
Streamer#
- Package:
github.com/air-verse/air/runner - File:
runner/proxy.go:27 - Methods:
AddSubscriber() *Subscriber RemoveSubscriber(id int32) Reload() BuildFailed(msg BuildFailedMsg) Stop() - Purpose: Abstracts the SSE broadcast mechanism used by the live-reload proxy. The
Proxystruct holds aStreamerfield, allowing the real fan-out implementation (ProxyStream) to be replaced in tests without standing up an HTTP server. - Implementations:
ProxyStream(runner/proxy_stream.go) — production; maintains amap[int32]*Subscriber, usessync.Mutexfor concurrent access andatomic.Int32for subscriber ID generation. Each subscriber owns achan StreamMessage.
- Design quality: Good, though slightly wider than strictly necessary.
AddSubscriberreturns the concrete*Subscribertype rather than an interface, which creates a coupling between theStreamerinterface and theSubscriberstruct. In practice this is fine for a small codebase; it would only matter if a secondStreamerimplementation needed a different subscriber shape. The five methods are coherent — all relate to managing SSE subscribers.
filenotify.FileWatcher (external, consumed by Air)#
- Package:
github.com/gohugoio/hugo/watcher/filenotify - File:
filenotify/filenotify.go(in the Hugo module at v0.147.6) - Methods:
Events() <-chan fsnotify.Event Errors() <-chan error Add(name string) error Remove(name string) error Close() error - Purpose: Provides a uniform interface over two file-watching strategies: native fsnotify (inotify/kqueue) and a poll-based fallback. Air’s
Enginestoreswatcher filenotify.FileWatcherand callswatcher.Add(path)for each watched directory. Therunner/watcher.gofactory selects the implementation based oncfg.Build.Poll. - Implementations (as used by Air):
fsNotifyWatcher— wrapsfsnotify.Watcher; used whenbuild.poll = falsefilePoller— polling implementation; used whenbuild.poll = true, required for Docker volumes and network filesystems where inotify events are unreliable
- Design quality: Clean 5-method interface that mirrors
fsnotify.Watcher’s shape but hides the implementation. The read-only channel return types (<-chan) correctly enforce that consumers only receive, not send. Air does not own this interface — it depends on a vendored copy from Hugo, which is an unusual coupling but acceptable for a small tool.
Interface patterns#
- Size distribution: Very small. The two project-owned interfaces have 1 and 5 methods respectively. The one consumed external interface has 5 methods. Average ~3.7 methods per interface — well within Go’s idiomatic preference for small interfaces.
- Embedding: No interface embedding is used. None of the interfaces compose via embedding.
- Implicit satisfaction: Both project-defined interfaces are satisfied implicitly (no
var _ Streamer = (*ProxyStream)(nil)compile-time guards).exiteris satisfied bydefaultExiter(in production) andtestExiter(in tests).Streameris satisfied byProxyStream. - Interface defined by consumer: Both Air-owned interfaces are defined in the same package (
runner) as their consumers.exiterandStreamerare defined next to theEngineandProxystructs that consume them, which is the idiomatic Go pattern (consumer defines the interface). - stdlib interfaces used: None directly. The codebase delegates file watching to
filenotify.FileWatcherwhich itself exposesfsnotify.Eventchannels. HTTP handler wiring useshttp.Handler(viahttp.HandleFunc) but no custom interface wrapping it.
Key abstractions#
exiter— the minimal testability boundary. With one method, it is the archetype of the Go “interface for a single seam” pattern. It lets the test suite verify thatengine.cleanupAndExit()sends the right exit code without the test process dying. A textbook example of adding an interface only where a test requires it.Streamer— SSE fan-out contract. Separates the HTTP proxy (Proxy) from the subscriber management and broadcasting logic (ProxyStream). The interface makesProxytestable by swapping in a no-op streamer. It also documents the protocol between the proxy and the live-reload subsystem: subscribe, signal reload/failure, and clean up.filenotify.FileWatcher— dual-backend file watching. Not defined by Air, but the most architecturally impactful interface in the project. It enables the polling fallback that makes Air work in containerized environments. Air’s design of storingwatcher filenotify.FileWatcherin theEnginestruct instead of a concrete type is the correct choice — it keeps the fsnotify/polling decision entirely inrunner/watcher.goand invisible to the rest of the Engine.
Interface-driven extensibility#
Air’s use of interfaces is deliberately minimal. The project has no plugin system, no extension points, and no public interface contracts intended for external callers. Both project-owned interfaces exist for a single reason: test isolation. The philosophy, stated implicitly in the architecture, is: add an interface only at a boundary that needs to be mocked in tests.
This is in contrast to frameworks (Gin, Echo, Fiber) that define rich interface hierarchies to enable middleware, custom backends, and third-party plugins. Air’s interface surface reflects its nature as a single-purpose developer tool: it does one thing, it does it well, and it keeps the abstraction overhead near zero.