skip to content

Package Boundaries

What a package promises the outside world: which identifiers get a capital letter, what hides under internal/, how dependencies are wired in main, and which later edits break importers.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

23

Why does a Go package usually export a blocking function instead of starting a goroutine for the caller?

level: juniorimportance: must knowfreq 55%

answer

  1. concurrency is easy to add, hard to remove
  2. one keyword in front of your call
  3. a goroutine has nowhere to return an error
  4. the caller owns shutdown, count and joining

basics

~20 s

A caller can always add concurrency to a blocking function by starting it with go, but cannot remove concurrency the package started. Staying synchronous leaves the goroutine's lifetime, its errors and how many run in the caller's hands.

solid answer

~50 s

The default in Go is that an exported function does its work on the goroutine that called it and returns when it is done. Adding concurrency is one keyword at the call site: the caller writes `go`, and they choose how many run at once, how they wait for them, and what they do with the error. Removing concurrency is impossible — once my package starts a goroutine, every program that imports me inherits it. A blocking function also has somewhere to put an error: it returns one. A goroutine has nowhere to return to, so packages that start their own usually end up logging errors, which is exactly the decision the caller wanted to make. So the rule of thumb is: export the synchronous call, and only start a goroutine yourself when the abstraction genuinely cannot exist without one — and then hand back a way to end it.

code

go · 13 lines
go
// Exported by the package: synchronous, returns an error.
func Scan(ctx context.Context, root string) ([]Result, error)

// Written by the caller, in their own code, when they want concurrency.
func scanTwo(ctx context.Context, a, b string) {
	done := make(chan struct{})
	go func() {
		defer close(done)
		_, _ = Scan(ctx, a)
	}()
	_, _ = Scan(ctx, b)
	<-done
}

go deeper

for a junior

Be ready to say the rule out loud: export the blocking function, let the caller write go. Know that a caller can add concurrency but cannot take away concurrency the package started.

for a middle

Explain the mechanics behind the rule: a blocking call returns an error on the normal path, while a goroutine has no caller to return to, so its errors end up logged or dropped, and its lifetime is invisible at the call site.

for a senior

Show the production consequences — shutdown ordering, tests that outlive their work, unbounded in-flight work — and state the conditions under which you would still start a goroutine, including the stop mechanism you would export with it.

for a principal

Frame it as an API promise the whole organisation inherits. Every embedder gets the goroutine whether it suits their process or not, and adding or removing one later changes their shutdown code, so treat it as a compatibility decision rather than an implementation detail.

## The rule In Go, the normal shape for an exported function is: do the work on the goroutine that called you, and return when the work is finished. `go` is a keyword any caller can type. Starting a goroutine on their behalf is not a service the package needs to provide. This sounds like a style preference. It is actually a promise about lifetime, and lifetime promises are the hardest part of an API to change later. ## Concurrency is easy to add and impossible to remove Given a blocking `func Scan(ctx context.Context, root string) ([]Result, error)`, a caller who wants it in the background writes `go`. They decide: - **how many** run at once — one, four, or one per CPU; - **how they wait** — a `sync.WaitGroup`, a channel they own, or nothing at all; - **what happens to the error** — returned up their own stack, collected, retried, or logged in their format; - **when it stops** — their `context.Context`, their deadline, their shutdown sequence. Now invert it. Suppose the package exported `func ScanAsync(root string) <-chan Result` and started a goroutine internally. A caller who wants the synchronous version has to fake it: drain the channel, guess when the work has ended, and hope the goroutine actually exits. They cannot un-start it. Every importer of the package now runs that goroutine whether their program suits it or not — including short-lived commands, tests, and other libraries that themselves wanted to control concurrency. One direction is a keyword. The other is a rewrite of somebody else's program. ## Errors need somewhere to go A synchronous function returns `error`, and the error travels back on the caller's normal path. A goroutine has no caller to return to. Once a package starts one, its errors have only bad destinations: - a log line, in whatever format and to whatever destination the package chose (now the caller inherits your logging decision too); - a channel the caller must remember to drain, or the sender blocks; - silence. When someone says "my library logs the error and continues", that is usually a symptom of a goroutine started at the wrong layer, not a logging problem. ## The other things a caller loses - **Shutdown ordering.** A long-lived process shuts down in a sequence — stop accepting work, drain, flush, exit. A goroutine the caller never started has no place in that sequence. - **Tests.** A test that finishes while a package's goroutine is still running is a flaky test and, under the race detector, sometimes a reported race in code the test was not exercising. - **Backpressure.** If the package starts one goroutine per call, load is now unbounded in a way the call site does not show. - **Readability at the call site.** `go doThing()` shows concurrency where it happens. A constructor that quietly starts three goroutines does not. ## When starting a goroutine is legitimate The rule is a default, not an absolute. Some abstractions cannot exist without background work: a component that multiplexes one connection between many callers, one that refreshes a cached credential before it expires, one that batches writes on a timer. In those cases the goroutine is the product, not a convenience. When you do it, the API must carry the whole promise: 1. **Do not start it in `New`.** Separate allocating the value from starting the work, so nothing runs until the caller says so. 2. **Export exactly one way to stop**, and make its return mean the goroutine has actually exited — not that a stop signal was sent. 3. **Bound the count.** "One goroutine per instance" is a documentable promise; "one per call" is a surprise. 4. **Give errors a path the caller owns** — a callback they supply, a method they can query, or a channel whose contract you document. 5. **Say all of it in the doc comment.** Once teams embed the package, the goroutine is part of the API, and adding or removing one changes their shutdown code. ## The short version for an interview Export the blocking call. Let the caller write `go`. Start a goroutine only when the type cannot do its job without one, and then export the off switch in the same breath as the on switch.

  • When is it legitimate for a package to start goroutines itself?
    When the abstraction cannot exist without background work — multiplexing one connection between callers, refreshing a credential before it expires, batching on a timer. Then the API must carry the whole promise: nothing starts in the constructor, there is exactly one documented way to stop, the stop returns only after the goroutine has exited, and the goroutine count is bounded and documented rather than growing with call volume.
  • What does a caller give up if a package starts the goroutine instead?
    Error handling, because a goroutine has no caller to return to and usually logs instead. Shutdown ordering, because the caller cannot join something they did not start. Concurrency limits, because the call site no longer shows how much work is in flight. And test hygiene: work that outlives the test it belongs to.
  • Does a blocking API cost the caller anything?
    It costs them the boilerplate of starting and joining goroutines themselves, and for a caller who only ever wants the background form, that is repeated at every call site. That is a real cost, but it is theirs to pay and theirs to shape — which is the point. If it becomes painful, a package can add an obviously named background wrapper alongside the blocking core rather than replacing it.

saying these in an interview costs you the question

  • Says an asynchronous API is always faster
  • Thinks the library should manage goroutines so callers do not have to
  • Returns a channel purely to make a function non-blocking
  • Argues goroutines are cheap, so starting one costs the caller nothing
  • Logs the error inside the goroutine and calls that error handling
  • Assumes a goroutine ends when the function that started it returns
open as a page

In Go, what makes an identifier visible outside its package, and what does an unexported struct field mean for importing packages?

level: juniorimportance: must knowfreq 85%

basics

~20 s

Case decides it. An identifier whose name begins with an upper-case letter is exported and visible to packages that import it; a lower-case one is package-private. Importing packages cannot read, set, or even name an unexported struct field.

open as a page

Where do ctx context.Context and the error result belong in an exported Go function's signature?

level: juniorimportance: must knowfreq 74%

basics

~20 s

A context.Context parameter goes first and is named ctx; the error result goes last. Everything else sits between them, with any variadic parameter at the end, because that is the shape the standard library and every reviewer expect.

open as a page

How does a Go service struct get its collaborators when main wires the program by hand?

level: juniorimportance: must knowfreq 68%

basics

~20 s

Through its constructor's parameters. main creates each collaborator first and passes them to a function such as NewThumbnailer, which stores them in unexported struct fields, so the type never reaches outside itself for a dependency.

open as a page

Why does adding a method to an exported Go interface break importers, when adding one to a struct does not?

level: middleimportance: must knowfreq 55%

basics

~20 s

An exported Go interface is satisfied implicitly, so any type with the right method set implements it. Adding a method enlarges that set, and every outside implementer stops compiling. A method on a struct affects only that type.

open as a page

Why should an exported Go API take a `context.Context` per call rather than storing one in its struct?

level: middleimportance: must knowfreq 52%

basics

~10 s

A context describes the lifetime of one operation. Stored in a struct, every call shares one lifetime, per-call deadlines become impossible, and the signature stops telling callers the work is cancellable.

open as a page

What does a `// Deprecated:` comment on an exported Go function do, and what happens to code that still calls it?

level: juniorimportance: should knowfreq 42%

basics

~20 s

A paragraph beginning "Deprecated:" in an identifier's doc comment marks it as no longer recommended. Nothing changes at compile time: existing calls still build and run. Documentation tools and editors surface the notice and the replacement it names.

open as a page

When is adding a field to an exported Go struct safe for importers, and when does it break them?

level: middleimportance: should knowfreq 40%

basics

~20 s

Safe when the struct already has an unexported field, because importers must then write keyed literals. It breaks unkeyed positional literals of all-exported structs, and it breaks == and map-key use when the new field's type is not comparable.

open as a page

What must a Go package document when an exported method returns a receive-only `<-chan Event`?

level: middleimportance: should knowfreq 42%

basics

~20 s

When it closes, what stops the producer, whether a slow reader blocks or loses events, and how a terminal error arrives. The caller cannot close a receive-only channel, so the package owns every one of those answers.

open as a page

What does moving a Go package under an internal/ directory let its author refuse to promise?

level: middleimportance: should knowfreq 58%

basics

~10 s

Everything in it. Code under internal/ is importable only from the subtree rooted at internal/'s parent, so your own packages share it while no other module can reach it. Nothing there is API.

open as a page

In Go, what do callers lose when a package's constructor returns an exported interface instead of the concrete struct pointer?

level: middleimportance: should knowfreq 60%

basics

~20 s

Every method and field the interface does not list. The returned value is narrowed to the declared method set, so callers cannot reach the rest, and the package now promises two exported things — the interface and the constructor — instead of one.

open as a page

Why should an exported Go loader take an io.Reader parameter instead of *os.File?

level: middleimportance: should knowfreq 52%

basics

~20 s

Take io.Reader, the smallest interface that covers what the function actually does. Callers can then pass a file, an HTTP response body or an in-memory reader in a test, and your package never forces a real file on disk.

open as a page

Why should an exported Go function declare its result as error rather than *ValidationError?

level: middleimportance: should knowfreq 58%

basics

~20 s

Declare the result as error, the interface type. A signature returning a concrete pointer such as *ValidationError hands callers a value that is not equal to nil even when the pointer is nil, so successful calls trip their error check.

open as a page

In a Go main, why should a constructor that opens a dependency return an error instead of calling log.Fatal?

level: middleimportance: should knowfreq 56%

basics

~20 s

Only the program's entry point can decide what a startup failure means. Returning an error lets it stop before the graph is half-built and close what is already open; log.Fatal calls os.Exit, which terminates immediately and skips every deferred cleanup.

open as a page

A downstream team's build breaks after upgrading your Go SDK's new minor tag. How do you find the cause and stop a repeat?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Reproduce from the consumer side: point a real importer's requirement at the new tag and run go build ./..., because the compiler names the break. Then diff the two exported surfaces, patch, and add a consumer build to CI.

open as a page

A file-watching package's `New` starts a goroutine still visible in the goroutine profile after your tests finish — what is wrong with its exported API?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The API started a lifetime it gave no way to end: work began in a constructor rather than a call the caller drives, and nothing exported signals that the goroutine has exited. The fix is a signature change.

open as a page

During shutdown your image service panics writing to a blob store main already closed, so how should main order shutdown?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Close in reverse construction order, and only once the work using a collaborator has stopped: stop accepting requests, drain in-flight handlers with http.Server.Shutdown, then close the blob store. Deferred calls already unwind in that order.

open as a page

Your team publishes a Go library other teams import. How do you decide what belongs in the exported API, and what does each exported name commit the team to?

level: principalimportance: should knowfreq 40%

basics

~20 s

Export the smallest set a real caller needs; everything else goes under internal/. Each exported name commits the team to documenting, keeping and supporting it, because removal breaks importers while adding one later is safe.

open as a page

A helper type your Go package exported by accident is now imported by three other teams. How do you shrink that surface?

level: seniorimportance: nice to knowfreq 35%

basics

~10 s

Not unilaterally. Find every real use, give those callers a narrow replacement, port them, then move the implementation behind internal/. Removing an exported name breaks importers' builds.

open as a page

A long exported Go function with named results ends a branch with a bare return, and callers get a zero value and a nil error. How do you find and prevent this?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

A bare return sends back whatever the named results currently hold, so a branch that forgets to set the error result returns a zero value with a nil error. Catch it with a test that asserts the returned value on the failing branch, not just the error.

open as a page

You maintain a Go client SDK dozens of teams pin, and a design flaw needs a breaking change. How do you decide what ships?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Weigh what the flaw costs callers against what a break costs them. Silent wrong results justify a break; ugliness does not. Prefer an additive path plus a deprecation notice naming the replacement and its removal release.

open as a page

Several teams embed your Go package in long-lived daemons — how do you decide whether it may start goroutines itself?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Treat it as a promise every embedder inherits. Default to blocking calls they wrap; start goroutines only when the abstraction cannot exist without them, and then commit to an explicit start, a bounded count, and a stop that means exited.

open as a page

In a service's main, how do you decide which collaborators must be reachable at startup and which may start degraded?

level: principalimportance: nice to knowfreq 31%

basics

~20 s

Refuse to start only for collaborators the service cannot keep its contract without: its store, its listener, its configuration. Optional ones such as a cache may start degraded, provided readiness and logs report that truthfully.

open as a page