Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?
Wire dependencies explicitly through constructors from one place in main, define narrow interfaces where they are consumed rather than implemented, and keep handlers thin enough that the logic beneath is testable without a server. With no framework, these are decisions rather than defaults.
What the interviewer is scoring
- Does the candidate wire dependencies explicitly rather than reaching for a container or globals
- Whether interfaces are defined by the consumer and kept narrow
- That handlers are kept thin so the logic under them is testable without HTTP
- Whether the candidate resists a deep package tree and can say what goes wrong with one
- Does the candidate address configuration and shutdown as part of structure rather than afterthoughts
Answer
The question is real because Go declines to answer it
Spring decides where your beans live. Rails decides where everything lives. Go decides nothing, so every team invents a structure and the inventions disagree. That is why this comes up in interviews: there is no canonical answer to recall, so what you say reveals whether you have maintained a large Go codebase or only written small ones.
The useful frame is that structure exists to keep two things cheap: finding where a rule lives, and testing it without standing up the world. Everything below is in service of those.
Wire it in main, explicitly
There is no container. Construct your dependencies in main, pass them down
through constructors, and let the compiler tell you what needs what.
func main() {
cfg := config.Load() // parse once, fail fast
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil { log.Fatal(err) }
defer db.Close()
// Concrete types flow downward. Nothing reaches upward for a global.
orders := postgres.NewOrderStore(db)
payments := stripe.NewClient(cfg.StripeKey)
svc := order.NewService(orders, payments)
srv := httpapi.NewServer(svc, cfg)
if err := srv.Run(context.Background()); err != nil { log.Fatal(err) }
}
This looks unfashionably manual and that is the point. The dependency graph is
one readable function, a missing dependency is a compile error rather than a
run-time surprise, and there is no reflection deciding anything. When the wiring
in main becomes genuinely unwieldy — which takes longer than people expect —
the answer is a code-generated wiring step, not a run-time container.
The habit this replaces is package-level state. A var DB *sql.DB set in an
init function is convenient for a fortnight and then means no two tests can use
different data, no component can be constructed twice, and nothing tells you
what a function touches.
Interfaces belong to the consumer, and stay small
The instinct carried in from Java is to define an interface next to its implementation and have callers depend on it. Go's convention is the reverse, and the reason is practical rather than stylistic.
// package order — the consumer declares only what it needs.
type OrderStore interface {
Save(ctx context.Context, o Order) error
ByID(ctx context.Context, id string) (Order, error)
}
type Service struct{ store OrderStore }
func NewService(store OrderStore, ...) *Service { return &Service{store: store} }
postgres.OrderStore never mentions this interface and does not import the
order package; it satisfies it implicitly by having the methods. So the
dependency arrow points from the detail toward the policy, the domain package
imports no database driver, and a test supplies a fake by defining a two-method
type inline.
Keeping the interface narrow is what makes that fake cheap. An interface mirroring all nineteen methods of your repository forces every test double to implement nineteen methods, so people reach for a mocking library, and now the tests assert on calls rather than on behaviour. Two methods, hand-written fake, no library.
Handlers thin, logic beneath
The single most valuable structural rule, because it decides whether your tests need a server.
// The handler does transport: decode, call, encode. No rules live here.
func (s *Server) createOrder(w http.ResponseWriter, r *http.Request) {
var req createOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed body")
return
}
out, err := s.orders.Place(r.Context(), req.toDomain())
if err != nil {
writeError(w, statusFor(err), err.Error()) // one place maps errors to codes
return
}
writeJSON(w, http.StatusCreated, out)
}
Place is an ordinary method taking a context and a value. It is tested by
calling it, with a fake store, in microseconds. Only the decode, the status
mapping and the encoding need a request, and httptest covers those without a
listening socket.
The failure this avoids is a handler containing the rules, which forces every test of a business condition to construct an HTTP request, and makes the condition untestable at all once there are six of them interacting.
Keep the tree shallow
Deep package trees are the most common self-inflicted wound in a Go codebase,
because Go forbids import cycles and a deep tree makes cycles easy to create.
Then someone breaks the cycle by extracting a package named common or
models, everything imports it, and it becomes the place where unrelated things
live together forever.
A workable default for a service is flat: cmd/ for the binary, one package per
domain concept, one package per external system, and nothing named for a layer.
Package names are part of the call site — order.Service reads well,
services.OrderService stutters — so name for the thing rather than the
category.
The related rule is that internal/ is real and worth using: anything under it
cannot be imported from outside the module, which is how you keep an
implementation package from becoming an accidental public API.
Configuration and shutdown are structure too
Both get treated as afterthoughts and both shape the code around them.
Parse configuration once, at startup, into a struct, and fail immediately if it is invalid. Reading environment variables deep inside a package means a misconfiguration surfaces on the first request that reaches that path rather than at boot, and it makes the component untestable without setting process state.
Shutdown is where a service either drains or drops requests. The pattern is a
context cancelled on SIGTERM, srv.Shutdown given a bounded timeout, and
every long-running goroutine selecting on the same context. A candidate who
mentions this unprompted has run something in a cluster, because it is the
difference between a rolling deploy that is invisible and one that returns
errors to users on every release.
What not to do
Do not import a layout from another ecosystem wholesale. The widely copied
pkg/-and-many-directories layout is not official and is heavier than most
services need; a small service does not benefit from eleven top-level
directories with two files each.
Do not add an interface for something with one implementation and no test that substitutes it. It costs a jump for every reader and buys nothing.
And do not reach for a dependency-injection framework because the wiring is long. Long explicit wiring is legible; reflective wiring moves your compile errors to start-up and your control flow somewhere you cannot read.
Go gives you no structure, so structure is a decision you make repeatedly. Wire explicitly, let consumers declare narrow interfaces, and keep the rules out of the handler — everything else is arrangement.
Likely follow-ups
- Where does the interface for your database live, and why there?
- How do you test a handler without starting a server?
- When is a global logger acceptable and when is it not?
- What breaks first when the package tree gets deep?
Related questions
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardAlso on interfaces and golang4 min
- Design a parking lot system — you have 90 minutes, working code at the end.hardAlso on interfaces5 min
- How do you test a service that calls a third-party API, without calling it?mediumAlso on testing4 min
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on golang4 min
- How do you test a Node service, and what do you refuse to mock?mediumAlso on testing5 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardAlso on dependency-injection5 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on golang5 min
- What are the three DI lifetimes in ASP.NET Core, and what goes wrong when you inject a scoped service into a singleton?mediumAlso on dependency-injection4 min