I ported the non-visual half of a Qt utility to Go and immediately missed signals and slots. They had been carrying progress, log messages, and completion notifications between objects. My first replacement was a channel for every event. It worked and looked like a telephone exchange designed by an enthusiastic octopus.

The simpler answer for synchronous notifications was a small interface:

type Progress interface {
    Step(done, total int)
}

func scan(files []string, progress Progress) os.Error {
    for i, name := range files {
        if err := scanFile(name); err != nil {
            return err
        }
        progress.Step(i+1, len(files))
    }
    return nil
}

The terminal implementation prints a line. A test implementation records calls. A future graphical shell can arrange delivery to its UI thread. The scanner knows only the behavior it needs.

This differs from a Qt signal in an important way: the call is ordinary and synchronous. The scanner waits while Step runs. That makes control flow and failure behavior obvious, but a slow observer slows the operation. If isolation is required, an adapter can implement Progress by sending values to a channel consumed elsewhere.

I prefer putting that asynchronous decision in the adapter rather than in the core interface. A channel everywhere turns every notification into a lifetime and shutdown problem. An interface everywhere can accidentally block. Neither mechanism removes design; they merely make different design visible.

For optional progress I use a no-op implementation rather than scattering nil checks through the scanner. It is one tiny type with one empty method, and it keeps the hot path unsurprising.

Completion remains a return, not another notification. The caller already has a direct control path, and duplicating it creates two accounts of whether work finished.

The Go compiler decides satisfaction structurally. My terminal reporter does not inherit from a framework base or declare that it implements Progress; matching Step is enough. That made extracting this contract from existing code much easier than I expected.

This is written against an early 2011 weekly snapshot, including the os.Error return convention. APIs will move. The lesson I am keeping is narrower: use a direct small interface for required behavior, then add a channel at the boundary where asynchronous delivery is actually needed. Not every callback needs its own postal service.