Air — Interfaces#

Interface catalog#

exiter#

  • Package: github.com/air-verse/air/runner
  • File: runner/exiter.go:5
  • Methods:
    Exit(code int)
  • Purpose: Wraps os.Exit so that tests can intercept process termination without killing the test runner. The Engine struct holds an exiter field instead of calling os.Exit directly.
  • Implementations:
    • defaultExiter (production) — delegates to os.Exit(code)
    • testExiter (in runner/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 Proxy struct holds a Streamer field, 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 a map[int32]*Subscriber, uses sync.Mutex for concurrent access and atomic.Int32 for subscriber ID generation. Each subscriber owns a chan StreamMessage.
  • Design quality: Good, though slightly wider than strictly necessary. AddSubscriber returns the concrete *Subscriber type rather than an interface, which creates a coupling between the Streamer interface and the Subscriber struct. In practice this is fine for a small codebase; it would only matter if a second Streamer implementation 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 Engine stores watcher filenotify.FileWatcher and calls watcher.Add(path) for each watched directory. The runner/watcher.go factory selects the implementation based on cfg.Build.Poll.
  • Implementations (as used by Air):
    • fsNotifyWatcher — wraps fsnotify.Watcher; used when build.poll = false
    • filePoller — polling implementation; used when build.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). exiter is satisfied by defaultExiter (in production) and testExiter (in tests). Streamer is satisfied by ProxyStream.
  • Interface defined by consumer: Both Air-owned interfaces are defined in the same package (runner) as their consumers. exiter and Streamer are defined next to the Engine and Proxy structs 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.FileWatcher which itself exposes fsnotify.Event channels. HTTP handler wiring uses http.Handler (via http.HandleFunc) but no custom interface wrapping it.

Key abstractions#

  1. 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 that engine.cleanupAndExit() sends the right exit code without the test process dying. A textbook example of adding an interface only where a test requires it.

  2. Streamer — SSE fan-out contract. Separates the HTTP proxy (Proxy) from the subscriber management and broadcasting logic (ProxyStream). The interface makes Proxy testable 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.

  3. 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 storing watcher filenotify.FileWatcher in the Engine struct instead of a concrete type is the correct choice — it keeps the fsnotify/polling decision entirely in runner/watcher.go and 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.