Cookies on Tamperan

We use cookies and similar technologies for the things below. You can accept all, reject everything except what's essential, or pick what you're OK with.

Preferences
Remembers things like your last workspace and how you had a list sorted. Improves the experience but the site works without.
Improvement
Anonymous usage measurement so we can fix bugs and prioritise work.
Marketing
Lets us measure whether ads we run send people who actually use the site. We don't share personal data with advertisers.

Read our cookies policy and the privacy policy.

Loading…

Writing

A library-centric Go monorepo, and the one rule that makes it work

38 shared libraries across 75 modules and a dozen production apps, run by one person. The thing that makes it survivable isn't the tooling - it's a rule about where a fix is allowed to go, and a worked example of paying it.

Shane Wright go monorepo architecture

The layout, so the rest has something to stand on. One workspace, two trees:

  • libs/ - 38 shared Go libraries, each its own module and its own repository
  • projects/ - applications grouped by product, 37 Go modules between them, a dozen of which are in production

75 go.mod files in total. go.work ties them together on a developer's machine so a change in a library is live in every consumer immediately. CI never sees the workspace: there, apps build against pinned versions, which is the only way to find out whether what you pushed actually works for somebody who hasn't got your working tree.

None of that's the interesting part. Plenty of people have a monorepo. The rule is what makes this shape survivable:

When an application hits a limitation in a shared library, the fix goes in the library.

Not a wrapper in the app. Not fifteen lines that work around it locally. The library gains the capability, the app consumes it, and the next consumer gets it for free.

That sounds obvious written down and is genuinely hard to hold to, because the app-side version is always cheaper today. Here is a complete instance of paying it, on 16 August 2026.

The limitation

I wanted a blog on one of the apps. The content server in our shared web library already served a folder-per-entry tree, which is what you want when a post has images sitting next to it:

content/writing/2026-08-17-otel-version-mismatch/
    _index.md
    hero.png

Serving worked. Listing returned nothing at all. The index page was empty with no error, which took a while to understand, and the cause is one line:

if strings.HasPrefix(filepath.Base(fname), "_") {
    continue
}

List skipped every file whose base name began with an underscore. That is correct for a flat layout - blog/a-post.md, where _index.md is the section page and should not appear as an entry inside its own listing. It is exactly wrong for a folder-per-entry layout, where every entry is an _index.md, so the filter removes the entire result set.

The fifteen lines I didn't write

The app-side fix was sitting right there:

// in the app, roughly
func listPosts(cs web.ContentServer, dir string) ([]post, error) {
    names, err := cs.Glob(dir + "/*/_index.md")
    if err != nil {
        return nil, err
    }
    out := make([]post, 0, len(names))
    for _, n := range names {
        doc, err := cs.Render(n)     // already exported, already caches
        if err != nil {
            return nil, err
        }
        out = append(out, post{Filename: n, Title: doc.Title, Published: doc.Published})
    }
    sort.Slice(out, func(i, j int) bool { return out[i].Published.After(out[j].Published) })
    return out, nil
}

Glob and Render are both exported, so this compiles, works, and is done in twenty minutes. I've written that function before in other jobs and so has everybody reading this.

Three things are wrong with it, and only the first is obvious.

It re-implements a filter it can't see. List doesn't just glob and render - it maps the front matter onto a struct, and that mapping grows. My fifteen lines would carry Title and Published, because that's what today's page needed. The library's version carries author, summary, last-modified, image, image alt text, tags and a sort weight. The next feature I wanted - a byline, then tag pages, then lastmod on the notes section - would each have been another field bolted onto the app's copy.

It puts the app one release behind its own dependency, permanently. When the library later gained Author on the list item, the app with its own listing function didn't get it. Nothing breaks. It just quietly diverges, and the divergence is invisible because the app compiles fine.

It is the exact code the next app will also write. Any application with a blog, a docs section, a changelog or a knowledge base needs this. A workaround in one app is a decision that the other eleven will each solve separately, slightly differently, and none of them will be wrong enough to notice.

What went in the library instead

An explicit option, with the default left alone so no existing caller moves:

// ListOptions varies what List includes. The zero value is List's long-standing
// behaviour, so an existing caller is unaffected by anything added here.
type ListOptions struct {
	// IncludeUnderscore keeps files whose base name starts with "_".
	//
	// List drops them so that section pages (_index.md) do not appear as
	// entries inside their own listing. That is right for a flat layout
	// (blog/a-post.md) and exactly wrong for a folder-per-entry one
	// (blog/a-post/_index.md, with its images alongside), where EVERY entry is
	// an _index.md and the unfiltered result is empty. Set this when the
	// pattern names the index files deliberately.
	IncludeUnderscore bool
}

// ListOpts is List with explicit options; see ListOptions.
func (c *cs) ListOpts(pattern string, opts ListOptions) ([]ContentListItem, error)

List(pattern) now calls ListOpts(pattern, ListOptions{}). Every existing caller in every other app is untouched, and the interface gained one method.

The consumer is two lines:

// Every entry in a folder-per-post tree IS an _index.md, which List drops -
// hence ListOpts with IncludeUnderscore.
var listOpts = web.ListOptions{IncludeUnderscore: true}

items, err := contentServer.ListOpts(writingDir+"/*/_index.md", listOpts)

The same commit fixed two neighbouring gaps that the app-side version would have had to work around separately: the rendered document never reached the template (so a content page could only ever be a slab of HTML, with the front matter parsed and then dropped), and YAML folded scalars kept their trailing newline, which travelled into a <meta content="..."> attribute and into both feeds.

48 lines of library, 65 lines of test.

The counter-argument, which is real

The app-side fix is one commit in one repository and you are finished before lunch.

The library-side fix is:

  1. change the library, with tests, because it now has consumers you cannot see
  2. commit and push it
  3. bump the dependency in the consuming app
  4. verify the app against the pin rather than the workspace
  5. and if the library is one an app already pins tightly, repeat 3 and 4 for every other consumer that needs to move

In practice that is somewhere between twenty minutes and an afternoon, against twenty minutes for the workaround. On the day, the workaround wins every time. This isn't a close call that discipline resolves - it's a real cost you're choosing to pay, and being straight about that's the only way the rule survives contact with a Friday.

What tips it is that the workaround's cost is paid by a different person on a different day, and usually more than once.

Where the line actually is

The rule is not "everything goes in a library". The question I actually ask:

Is this concern the application's, or the domain's?

  • A blog index is the domain's. Anything with content has one.
  • A route only this one site wants is the application's.
  • A filesystem overlay that unions a content/ tree with a drafts/ tree in development is the domain's - it was written for one app's draft preview and is now general-purpose.
  • The specific set of tags that site uses is the application's.

When it's genuinely unclear, the tiebreak is: would a second consumer want this? If yes, library. If you can't picture the second consumer, app - and being wrong in that direction is cheap, because promoting app code into a library later is a move you can make, while un-picking a library abstraction that only ever had one user isn't.

The tooling that makes it survivable

Three commands, and they exist because 75 modules is past the point where you can hold the dependency graph in your head:

tam ws sync     # git status + pull in every workspace module
tam ws check    # does every module pin the latest commit of its in-workspace deps?
tam ws deps     # update interdependencies, oldest dependency first

deps going oldest-first is the part that matters. Libraries depend on other libraries, so updating in arbitrary order means bumping a module to a version of its own dependency that doesn't exist yet, and the errors that produces point everywhere except at the ordering.

And the one that catches the real failure:

GOWORK=off go build ./...

Inside the workspace, every library resolves to your working tree, so everything compiles and proves nothing about what CI will see. GOWORK=off builds against the pins. If it fails, you pushed a library change and forgot to bump a consumer, which is the single most common way this shape breaks.

Where it stops

A change that has to land in every consumer at once. One library, one breaking signature, twelve applications - that's twelve dependency bumps, twelve builds and twelve deploys, and there's no atomic version of it. The answer so far has been to make library changes additive almost always (ListOpts beside List, rather than a new signature on List), which is more code and worse API design than I would choose if I could move everything at once. That is a real tax and it is levied on every change.

Consumers you can't see. Everything here is mine, so "who calls this" is a grep. If the consumers were other teams, every one of those additive workarounds would become a deprecation cycle with a timetable, and the rule as stated would be far more expensive to follow.

A library with exactly one consumer forever. That is the failure mode of the rule applied too enthusiastically, and it costs you a repository, a release cycle and a version pin in exchange for nothing. I've three that are close to this line and I haven't moved them back, which is worth stating since it's the same reluctance the rule exists to overcome, pointed the other way.

What happens when the consumers are other people

The rule is "fix it in the library". The problem with other people's deadlines is not that they disagree with it - it is that a fix in the library is not a fix in production until somebody bumps a dependency, and bumping a dependency is never the most urgent thing on anyone's board.

So dependency updates have to be tickets, on a cadence. Not a good intention and not a quarterly clean-up: scheduled, tracked work with an owner, the same as anything else that has to happen. That sounds like overhead until you notice you need it anyway for security patching - the cadence that gets a CVE out of your estate is the identical cadence that gets a library fix into it. One piece of machinery, two problems, and only one of them is optional.

Without it the rule degrades in a specific and familiar way: the fix exists, the library version containing it is correct, and the bug is still live in four applications because nobody scheduled the upgrade. Then somebody works around it locally, and the workaround is the thing the rule existed to prevent.

What it actually costs

You can't be lazy on a Friday.

Every shortcut in this shape is visible - it's an app-side workaround, sitting in a file, being read by whoever opens that app next. There is no version of "I'll tidy it up later" that stays hidden, which is the property that makes the rule enforceable and also the property that makes it tiring.

The tooling helps with the mechanics and not at all with the decision. tam ws deps will bump the pins; nothing tells you that the fifteen lines you are about to write in an app are the eleventh copy of something that should have been shared four apps ago. That judgement has stayed manual and I don't have a good way to automate it.

If you're running something this shape and you have found one, I would like to hear about it.