skip to content

How do you lay out a Go repository so it can publish both v1 and v2 of the same module?

level: middleimportance: nice to knowfreq 35%

answer

  1. a subdirectory or a branch
  2. each major needs its own go.mod
  3. consumers cannot tell which you chose
  4. duplication versus discoverability
  5. the module's own imports change too

basics

~20 s

Two layouts work. Put v2 in a v2/ subdirectory with its own go.mod whose module line ends in /v2, or keep it on a branch whose root go.mod ends in /v2. Either way the module's own imports must gain /v2.

solid answer

~50 s

The go command supports a **major version subdirectory** and a **major version branch**, and they are equally valid. With the subdirectory, the repository grows a `v2/` directory holding the v2 tree and its own `go.mod` saying `module example.com/lib/v2`; v1 keeps living at the root. With the branch, one branch's root `go.mod` carries the `/v2` path while another branch keeps v1. The subdirectory makes both majors visible in one checkout, which is handy when you want to share test data or build them together, at the cost of a duplicated tree that fixes must be applied to twice. The branch keeps each major a single clean copy and matches how most teams already run maintenance branches, at the cost that nothing on the default branch reveals v2 exists. The trap in both is internal imports: every import the module makes of its own packages has to gain `/v2`, or the v2 code silently compiles against v1's packages.

code

text · 6 lines
text
lib/
  go.mod          module example.com/lib
  store/store.go
  v2/
    go.mod        module example.com/lib/v2
    store/store.go

go deeper

for a junior

Know that v2 lives in the same repository, either in a v2/ subdirectory or on its own branch, and that whichever you pick the v2 tree carries its own go.mod with the suffixed module path.

for a middle

Compare the two layouts on their real costs: a duplicated tree that fixes must be applied to twice, versus a major that is invisible from the default branch. Be able to name the internal-import rewrite as the step people forget.

for a senior

Talk about maintaining both majors over a support window — where shared fixes go, how CI covers both, and how you would develop an adapter between v1 and v2 types under each layout.

for a principal

Frame it as a maintenance budget decision, since consumers see no difference. Say how long v1 gets fixes, who applies them, and what would make you avoid the second tree altogether by keeping the change inside v1.

## The two supported layouts Once you decide a Go library needs a `/v2` module path, you have to put the v2 source somewhere that the go command can find and that you can maintain. Two arrangements are supported, and the choice is a maintenance question rather than a technical one. ### Major version subdirectory The repository keeps v1 at the root and grows a directory named after the major version: ``` lib/ go.mod -> module example.com/lib store/ v2/ go.mod -> module example.com/lib/v2 store/ ``` The `v2/` tree is a module in its own right, with its own `go.mod` declaring the suffixed path. A consumer importing `example.com/lib/v2/store` gets the code under `v2/store`. **What it buys:** both majors are visible in one checkout, so you can diff them, share fixtures or golden files across them, and run both test suites in a single CI job. It also works without any branch discipline at all, which matters when several people cut releases. **What it costs:** the tree is duplicated on your default branch. Every bug fix that applies to both majors has to be applied twice, or copied across, and reviewers see two copies of everything in the file listing. Over a long v1 support window that duplication is the real bill. ### Major version branch The alternative is one copy per branch. The v2 branch's root `go.mod` says `module example.com/lib/v2`; the v1 maintenance branch keeps `module example.com/lib`. Many projects run this as "the default branch becomes v2, and v1 moves to a maintenance branch", which is exactly how most teams already handle release maintenance. **What it buys:** a single clean copy of each major, ordinary cherry-picks between branches for shared fixes, no duplicated review noise. **What it costs:** nothing in a default-branch checkout tells a reader that v2 exists, so discoverability lives in the README. You also cannot build or test both majors from one working tree without switching branches, which makes an interoperability shim between them awkward to develop. ## The mistake that catches almost everyone The module path is not the only thing that changes. **Every import the module makes of its own packages must also gain the suffix.** If `v2/api/handler.go` still imports `example.com/lib/store`, that import resolves to the *v1* module. The result compiles, so nothing warns you, and every consumer of v2 quietly pulls v1 into the build as well — with two copies of the package's state and types that will not interoperate at the seam. Rewriting internal imports is a mechanical step, and it is the one to check first when a v2 release behaves strangely. The same applies in reverse to anything that names the path in text: code generation directives, embedded documentation, and example snippets in the README all carry the old path until you change them. ## Choosing between them A rough decision rule that holds up: - **Short v1 support window, small library, or an active team that would rather not duplicate files** — use a branch. Most Go libraries end up here. - **Long overlapping support, or v1 and v2 that must share substantial internals or test data** — use a subdirectory, because having both trees in one checkout is worth the duplication. - **A monorepo where tooling assumes one module per directory** — the subdirectory layout tends to fit that tooling better, since v2 simply looks like another module in the tree. Both layouts are equally legitimate to the go command; consumers cannot tell which one you picked, because all they ever see is the module path `example.com/lib/v2`. That is worth saying out loud in an interview, because it makes clear the decision is about your maintenance burden and nothing else. ## One structural alternative worth considering first Before either layout, ask whether the change can live as a *new package inside the existing module* — an `example.com/lib/store2`, or a new constructor and option type beside the old one. That keeps one module, one path and one release stream, and it is why many long-lived Go libraries have never cut a v2 at all. The layout question only arises once you have decided the break is genuinely worth a new major.

  • Besides the module line, what else must change inside the v2 tree?
    Every import the module makes of its own packages. An import of `example.com/lib/store` inside the v2 tree still resolves to the v1 module, so v2 would compile against v1's packages and drag them into every consumer's build. Rewriting internal imports to `example.com/lib/v2/store` is mandatory, and it fails silently if you skip it.
  • Can a consumer tell whether you used the subdirectory or the branch layout?
    No. All a consumer ever sees is the module path `example.com/lib/v2` and the versions published under it; the go command resolves either layout identically. That makes the choice purely a maintenance one — duplication in one checkout versus one copy per branch.
  • When is the subdirectory layout the better choice?
    When both majors will be supported for a long time and share substantial internals, fixtures or golden files, since having both trees in one checkout lets you test them together and develop a shim between them. It also suits monorepo tooling that expects one module per directory.

saying these in an interview costs you the question

  • Thinks only one of the two layouts is supported
  • Leaves internal imports pointing at the v1 path
  • Believes consumers can see which layout was used
  • Says v2 needs a separate repository
  • Forgets the v2 tree needs its own go.mod