Skip to content
QSWEQB
mediumDesignCodingMidSeniorStaff

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.

5 min readUpdated 2026-07-26

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

Further reading

project-structuredependency-injectioninterfacestestinggolang