Should a Go package's exported API hand work over a channel or hide a mutex behind ordinary methods?
answer
- what does the signature promise forever?
- an internal lock is yours to change
- an exported channel is everyone's
- who pays when a caller drains wrongly?
- export a channel only for select
basics
~20 sDefault to ordinary methods with synchronization hidden inside; return a channel only when callers must select on it or consume a stream. A channel in an exported signature freezes buffering, direction, lifetime and cancellation into the contract.
solid answer
~50 sConcurrency in an exported signature is a promise you cannot quietly revise. A method that takes a `context.Context` and returns a result keeps every choice inside the package: today a mutex, tomorrow something else, with no caller affected. Exporting a channel does the opposite — its direction, its buffering, who closes it and what happens on cancellation all become behaviour callers write against, and each caller must run a goroutine and drain it correctly or leak. So the bar for a channel in a public API is that the caller genuinely needs to compose it in a `select`, or needs a stream whose items arrive over time. Below that bar, hide the concurrency. The argument across teams is about who absorbs change: an internal mutex is your cost, an exported channel is everyone's — which is what an API review exists to weigh.
code
go · 6 lines// Caller must run a goroutine, drain to the end, and get cancellation right.
// Buffering, who closes it, and cancellation semantics are now contract.
func (s *Store) Watch(ctx context.Context) <-chan Event
// Caller keeps control; the synchronization inside is free to change.
func (s *Store) Next(ctx context.Context) (Event, error)go deeper
Recall that a package can keep its locking private and still be safe for concurrent use, and that a function returning a channel asks the caller to do more work than one returning a value.
Explain what an exported channel commits you to beyond its element type — direction, buffering, who closes it, cancellation — and why hidden synchronization leaves you free to change the implementation.
Show where you would draw the line in a real package: the caller needing to select is the test, and you can name the leak a caller causes by abandoning a channel your producer is filling.
Own the decision and its cost: state the default for the codebase, weigh who absorbs future change and caller mistakes across teams, and be ready to be overruled at review or to run the migration when an early export turns out wrong.
## What is really being decided The technical difference between a mutex and a channel inside your package is small and reversible. The difference at the package boundary is neither. Once other teams import you, the shape of your exported API is a contract, and in Go a channel in a signature carries far more contract than it looks like it does. Ask what each shape commits you to. **Ordinary methods, synchronization hidden.** `func (s *Store) Get(ctx context.Context, id string) (Doc, error)`. The caller sees a function call. You have committed to the arguments, the results, and the behaviour under cancellation — and nothing else. You may guard the state with one lock today, split it, cache in front of it, or replace it with an owner goroutine, and no caller changes a line. **An exported channel.** `func (s *Store) Watch(ctx context.Context) <-chan Event`. You have now committed to: the direction; whether it is buffered and how deeply, because that decides whether a slow consumer blocks your producer; who closes it and when, because a caller ranging over it depends on that; what happens to in-flight items on cancellation; and whether calling it twice gives two independent streams. None of that is in the signature, all of it is in the behaviour, and every one of those is something a caller will come to depend on — including the parts you did not intend to promise. ## The cost you push onto every caller A channel-returning API obliges each caller to introduce concurrency they may not have wanted: run a goroutine, drain the channel for its whole lifetime, and get cancellation right. A caller who returns early without draining leaks your producer goroutine, and the leak is in your package's stack traces, not theirs. Multiply that by the number of importing teams and you have shipped a footgun with your library, and you will be the one triaging it. The callback shape has the mirror problem — you now run caller code on your goroutine, with their panics, their blocking and their re-entrancy — so it is not a free alternative either. ## When a channel is genuinely the right export There are real cases, and they share a property: **the caller needs to compose the wait**. - The caller must wait on your events *and* on something else at once, which in Go means `select`, which means it needs a channel. - The API is inherently a stream of items arriving over time with no natural end, where a call-per-item API would be a busy loop. - The value is genuinely a signal — a done or ready channel — rather than data. Even then, prefer the smallest commitment: return a receive-only channel, document who closes it and when, document the buffering, tie the lifetime to the `context.Context` you were passed, and consider offering an iterator or a `Next`-style method as the primary API with the channel as the escape hatch. ## The organisational half This is where the decision stops being a matter of taste. Three questions decide it in review: 1. **Who absorbs the change?** An internal mutex is a cost you carry alone. An exported channel means any future change to buffering, closing or lifetime is a coordinated migration across every importing team, on their schedule. 2. **What is the blast radius of a caller getting it wrong?** For methods, a bug is in the caller. For a channel, a caller's missing drain leaks goroutines inside your package and lands in your on-call rotation and your issue tracker. 3. **Is the concurrency the caller's problem or yours?** If your package is solving a concurrency problem, keep it inside. If the caller's own design is concurrent and needs to compose with you, give them the channel. A useful default to state as a team rule: **exported APIs are call-and-return with a `context.Context`; channels stay internal unless a caller has to `select` on them.** Write it once, apply it in review, and require the exception to be argued rather than assumed. The point is not that the rule is always right — it is that the exception should be visible, because it is the expensive direction. ## Buying room to change your mind Before the package is widely imported, the cheap moves are worth taking: keep the concurrent shape unexported first and add it when a second caller asks for it; expose the simple method and let a channel-based helper live in a subpackage; ship the stream shape behind an interface you can extend rather than a bare channel. After the imports exist, all of those become migrations, which is exactly why this is decided at API review and not in the pull request that adds the feature.
- What exactly becomes contract when you export a channel, beyond its element type?Its direction; whether it is buffered and how deeply, since that decides if a slow consumer stalls your producer; who closes it and when, because callers range over it; what happens to in-flight items when the context is cancelled; and whether two calls yield independent streams. None of that appears in the signature, and all of it is depended on.
- You already shipped a channel-returning API and now regret it. What do you do?Add the call-and-return method beside it, migrate your own callers first to prove it covers the cases, then work through importing teams with a stated timeline before removing the channel form. The cost is the coordination, not the code. Capture the lesson as a team rule so the next package does not repay it.
- Is a callback parameter a safe way to avoid exporting a channel?It trades one liability for another. You now execute caller code on your goroutine, so their panic, their blocking call and their re-entrant call into your package are all your problem, and you must document which is allowed. It is a reasonable choice when the callback is short and clearly specified, and a poor one when callers will do real work in it.
- How would you argue this in an API review without relying on taste?Frame it as who absorbs future change and who absorbs caller mistakes. Internal synchronization means you can change the implementation unilaterally and a caller bug stays in the caller. An exported channel means any change is a cross-team migration and a caller's missing drain leaks goroutines inside your package. Then ask what the caller actually needs to select on.
saying these in an interview costs you the question
- Exports a channel because it looks more idiomatic
- Treats buffering and closing as private implementation details
- Ignores that every caller must now run a goroutine
- Assumes a public signature can be reshaped later cheaply
- Offers a callback without specifying panic and blocking rules