Repository Go Modules
Nexus is one Go module — github.com/frankbardon/nexus — plus a small number of
submodules under modules/, each with its own go.mod.
This page is the contract for that arrangement: where a submodule may live, how it resolves the core module, how the build and CI cover it, and how it is versioned. If you are writing an object-store backend, an exporter or anything else that needs a dependency Nexus core must not carry, start here.
Why submodules exist at all
The root module deliberately carries no vendor SDKs. Every LLM provider is raw
net/http, storage is pure-Go modernc.org/sqlite, and the direct dependency
count is a number the project defends.
A cloud object-store backend cannot honour that. The AWS and Google SDKs are
large, transitively deep, and pull in their own HTTP, auth and retry machinery.
Adding one to go.mod would impose it on every nexus build, including builds
that will never touch a bucket.
So the seam is designed to be implemented from outside:
pkg/engine/objectstore imports nothing beyond the standard library, and its
conformance suite pkg/engine/objectstore/objectstoretest is exported. A
backend lives in its own module with its own dependency graph, and an embedder
who wants it blank-imports it into their own main. The root module’s
dependency list does not move.
Layout
nexus/
go.mod # the root module: github.com/frankbardon/nexus
pkg/ plugins/ cmd/ ... # all of it root-module code, no exceptions
modules/
objectstore-s3/ # github.com/frankbardon/nexus/modules/objectstore-s3
go.mod
objectstore-gcs/ # github.com/frankbardon/nexus/modules/objectstore-gcs
go.mod
objectstore-seamcheck/ # github.com/frankbardon/nexus/modules/objectstore-seamcheck
go.mod
That is every submodule that ships today, and the list is short enough to be worth naming rather than gesturing at:
| Module | What it is | Dependency it carries |
|---|---|---|
objectstore-s3 | objectstore.Backend for Amazon S3 and every S3-compatible store. Registers as s3. See Object Storage. | AWS SDK for Go v2 |
objectstore-gcs | objectstore.Backend for Google Cloud Storage. Registers as gcs. See Object Storage. | cloud.google.com/go/storage |
objectstore-seamcheck | Not a backend and stores nothing. The permanent canary that keeps objectstore.Backend and objectstoretest.RunSuite usable from a module that is not the root one. | none |
make submodules prints the same list from the filesystem, and is the
authoritative answer if this table ever falls behind.
Rules:
- Every non-root
go.modlives atmodules/<name>/go.mod. One level, no nesting.make check-modules(run as part ofmake lint) fails the build if ago.modappears anywhere else. - Module path mirrors the directory:
modules/<name>becomesgithub.com/frankbardon/nexus/modules/<name>. This is not a style preference — Go derives a submodule’s version tag from its directory, so path and directory cannot diverge. - Names are flat and hyphenated, describing the seam then the implementation:
objectstore-s3,objectstore-gcs. Grouping them asmodules/objectstore/s3was rejected because it makesmodules/objectstorelook like a module it is not, and lengthens every version tag for no gain.
Placing a backend beside the interface it implements — pkg/engine/objectstore/s3/
— reads better and was rejected anyway. pkg/ means “root-module code” with no
exceptions worth remembering, and a nested go.mod under a directory everything
else sweeps is exactly the invisible-package trap described below.
Why there is no go.work
A workspace is the obvious way to make a multi-module repo build as one, and it
is deliberately not used here. go.work is in .gitignore.
A workspace merges every listed module into a single build list. The cloud
SDKs required by the backend modules would then take part in version selection
for the root module as well, and bin/nexus built by a contributor with a
workspace active could resolve different transitive versions than the binary CI
and the release build produce. Guaranteeing that the root module’s dependency
graph is exactly what its go.mod says is the entire reason the backends were
split out; a committed go.work would quietly give that back.
A workspace also does not buy what people assume. go test ./... does not
span workspace modules — ./... still stops at the current module — so a
go.work would not have removed the need for the Makefile to walk modules/.
Instead, each submodule carries a replace:
require github.com/frankbardon/nexus v0.18.2
replace github.com/frankbardon/nexus => ../..
The replace is scoped to that one module and affects nothing else in the tree.
Local development and CI therefore always build the submodule against the
working tree, which is what makes a submodule capable of failing when a change
to the seam breaks it. Anyone who depends on the submodule from outside this
repository ignores the replace — Go honours replace only in the main module —
and gets the required version, which is why that line must always name a real
published tag rather than a v0.0.0 placeholder.
go.work and go.work.sum are gitignored rather than merely absent, so a local
workspace for editor or debugging convenience is fine. Just never commit one.
Build, test and lint coverage
The failure mode this plumbing exists to prevent: a separate module is
invisible to every ./... pattern. go build ./..., go test ./...,
go vet ./... and staticcheck ./... all stop at a nested go.mod without a
word. A submodule that does not compile, or whose tests fail, reports as success
forever.
The Makefile therefore discovers submodules by glob and sweeps each one:
GO_SUBMODULES := $(patsubst %/go.mod,%,$(wildcard modules/*/go.mod))
| Target | Root module | Submodules |
|---|---|---|
make build | builds cmd/nexus + cmd/nexus-broker | go build ./... (compile check; submodules ship no binary) |
make test | go test ./... | go test ./... |
make test-objectstore-minio | — | modules/objectstore-s3 only, -tags minio |
make test-objectstore-fake-gcs | — | modules/objectstore-gcs only, -tags fakegcsserver |
make test-race | go test -race ./... | go test -race ./... |
make fmt | go fmt ./... | go fmt ./... |
make vet | go vet ./..., then again with -tags $(LINT_TAGS) | same, both passes |
make lint | vet + check-events + check-modules + staticcheck, untagged and -tags $(LINT_TAGS) | staticcheck, both passes |
make check-events | root only | — |
make check-modules | fails on a go.mod outside modules/<name>/ | — |
make submodules | prints the discovered list | — |
LINT_TAGS defaults to integration,evalrecord,minio,fakegcsserver — every build
tag in the tree that gates Go files. The second pass exists because a file behind
//go:build minio compiled only when someone ran that suite, so neither vet nor
staticcheck had ever seen it on any commit, CI included. One combined list is
applied everywhere rather than a per-module list: a tag matching no file in a
module is a no-op, so minio costs nothing in the GCS module.
It does not cover wasip1, which is a GOOS constraint rather than a build tag —
-tags will not reach plugins/tools/codeexec’s wasm files. make verify-yaegi-wasm is what exercises those.
Three deliberate exceptions:
-
The emulator targets are not sweeps. Each runs one submodule’s build-tagged suite against an emulator the target starts and stops itself:
test-objectstore-minioagainst MinIO viascripts/with-minio.sh, andtest-objectstore-fake-gcsagainst fake-gcs-server viascripts/with-fake-gcs.sh. Two targets rather than one shared “emulator” target, because MinIO emulates S3 and fake-gcs-server emulates GCS — folding them together would mean one red step for two unrelated stores, and neither suite could be run on its own while working on its own backend. Everything above stays untagged, which is what keepsmake testoffline and secret-free even though it sweepsmodules/.The two scripts are the same shape on purpose — a pinned emulator version, a readiness wait, a port nothing else can be holding, an EXIT trap, and a
NEXUS_TEST_*_REQUIREDvariable that turns the suite’s no-emulator skip into a failure so a provisioned run cannot pass by skipping — and differ in one place:with-minio.shruns a pinned container, whilewith-fake-gcs.shbuilds a pinned Go binary withgo install <module>@<version>, because fake-gcs-server is a Go module and MinIO is not. That means the GCS emulator suite needs no container runtime at all. Each script records the reasoning and the alternatives that were rejected.Both suites are also where the kill-and-resume cycle is proven against a real store, which is why both
modules/objectstore-s3/go.modandmodules/objectstore-gcs/go.modcarry indirect requirements —modernc.org/sqlite,gopkg.in/yaml.v3,klauspost/compress— that have nothing to do with either cloud. They come from the root module’spkg/engine, which those tests import so they can drive a real engine against a real bucket; no non-test file in either module imports anything abovepkg/engine/objectstore. The direction that matters is unchanged: the root module still does not require either one.The cycle itself is written once, in
pkg/engine/objectstore/enginetest, and each module supplies only the four store-specific hooks it needs — register a factory, make an empty bucket, list the bucket, read one object. It is exported from the root module for the reasonobjectstoretestis: a backend may live in a module this repository never sees, and passing the interface conformance suite does not prove a session resumes from it. It is a separate package fromobjectstoretestbecause it importspkg/engine, andpkg/engine’s own tests arepackage engineand importobjectstoretest— so the two halves have to sit in different packages or neither builds. -
check-eventsstays root-only.scripts/check-event-versions.shcds to the repository top level and inspectspkg/events/alone. Event structs live in the root module and nowhere else, so running it per submodule would repeat the identical check while looking like it were checking something else. -
go run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)resolves independently of the current module, so the same pinned staticcheck runs inside a submodule without that module having to require it.
CI needs no submodule-specific job for the sweeps: .github/workflows/ci.yml
runs make build, make test, make test-race, make vet and make lint, and
those cover modules/ already. A build-tagged emulator suite is the one thing
that does need a workflow edit, because no sweep runs it — the
objectstore-minio and objectstore-fake-gcs jobs exist for that, one per
emulator, and each runs the same make target a developer runs. That is the
design — one command per concern, shared verbatim between CI and a developer’s
terminal, so the two cannot drift into a state where CI skips something. Adding
a module under modules/ requires no workflow edit; adding an emulator suite
to it does.
Dependabot covers the submodules through a glob (directories: [/, /modules/*])
for the same reason.
Versioning and tagging
The core module is released as a bare tag, vX.Y.Z, with a matching GitHub
Release. Nothing about submodules changes that, because a bare vX.Y.Z tag
never versions a submodule — Go requires the tag to be prefixed with the
module’s directory.
The rules:
- Submodule tags are not cut by default. Cutting a core release does not cut
modules/*tags, and the release process does not have to know how many submodules exist. Inside the repository a submodule’s version is irrelevant anyway:makeand CI always build the working tree through thereplace. - A submodule tag is cut on demand, when someone needs to
go getthat module into a program built outside this repository. The tag format is fixed by Go:modules/<name>/vX.Y.Z, for examplemodules/objectstore-s3/v0.1.0. - Submodule versions are independent of the core version. They are not kept
in step with
vX.Y.Zand must not be assumed to match. A backend whose SDK needs a patch release should not have to wait for a core release, and a core release should not imply that every backend was re-tested. - When a submodule tag is cut, bump its
require github.com/frankbardon/nexusline to the newest core tag first, in the same commit. That line is what external consumers actually resolve, and a stale one gives them a core module older than the seam the backend was written against. Thereplacestays where it is — consumers ignore it. - Compatibility is expressed by that
requireline, not by a naming convention. “Which Nexus does this backend work with” is answered by readingmodules/<name>/go.mod, and by the fact that CI builds it against the current tree on every commit.
Adding a submodule
mkdir modules/<name>and writego.modwith module pathgithub.com/frankbardon/nexus/modules/<name>, arequireon the newest core tag, andreplace github.com/frankbardon/nexus => ../...- Write the code. Depend on whatever you need — that is the point.
make build && make test && make lint. The glob picks the module up with no Makefile, CI or Dependabot edit.- Prove the coverage is real: break something in the new module on purpose and
confirm
make buildandmake testgo red. If they stay green, the module is in the wrong place or the plumbing has regressed.
modules/objectstore-seamcheck is the worked example, and is also the permanent
canary for step 4: it is not a backend and stores nothing, it exists to hold true
the property that objectstore.Backend and objectstoretest.RunSuite are usable
from a module that is not github.com/frankbardon/nexus.