Module Development Guide#
Audience. This is the developer-facing reference for Lesstruct Tier 1 modules: first-party Go features compiled into the binary (
modules/<name>/, each its own Go module).If you want sandboxed third-party extensions instead, write a WASM plugin — see plugin-development.md. Tier 2 plugins hook events at runtime; Tier 1 modules ship behavior at compile time. Both live on one event bus of concepts: modules own the kernel contracts, plugins consume hook payloads shaped like them.
The kernel (kernel/, Go module
github.com/aristorinjuang/lesstruct/kernel, currently v1.0.0) is the
contract. Read kernel-versioning.md first: semver
rules, stability classes, and the deprecation flow — especially the trap
that adding a method to an exported interface is a breaking change.
Module anatomy#
Every first-party module follows the same layout (see modules/users and
modules/auth for the full verticals, modules/media for the
single-source store pattern):
| |
module.go is options-built: the composition root supplies every
collaborator via WithXxx options, New builds the handlers once, and
typed accessors (UserManagementHandler(), …) expose them for explicit
mounting. Requires() declares hard dependencies — it is not documentation:
the registry topologically sorts on it, and the operator-facing
[modules] disabled cascade drops transitive dependents from it
(auth requires users; comments requires users; adminpanel
requires auth). Design Requires() as the disable cascade you want
operators to get. Init validates presence; real wiring happens in New.
Register new modules in the SUBMODULES list in the root Makefile so the
test/lint gates cover them alongside the root module.
Kernel contracts reference#
| Contract | Use it when… |
|---|---|
kernel.Module / kernel.Kernel / Registry + InitAll | Always — the lifecycle every module implements. |
Capabilities[T] | Discovering sibling modules instead of a service locator (theme providers, migrators, route providers, extension manifests). |
Principal + PrincipalFromContext / ContextWithPrincipal | Reading the acting identity in handlers and services. Parse the decimal ID with the principalID pattern (fail closed on kernel.ErrInvalidPrincipalID); never invent a second identity shape. |
RouteProvider / PublicRouteProvider | Mounting routes inside the existing protected/public groups. If your surface spans realms, body-limit groups, or per-route flags a single call cannot express, use a structural surface interface in internal/api/routes instead (the documented media/comments/auth/users deviation). |
ContentTypeProvider, RoleProvider, ThemeProvider, AdminExtensionProvider, Migrator | Contributing post types, capabilities, themes, admin UI, or versioned migrations. |
Logger (nil degrades to discard) | All module logging. |
Prune | Never call directly — the composition root owns the disabled list. Know that it reads your Requires(). |
Published-language libraries (no adapters needed, import directly):
httpx (the one response envelope — byte-identical to the legacy writers
by contract), content, customfield, database (Driver/Dialect),
storage, seo, sanitize, tiptap, i18n, posttype, capability,
conformance.
Seams and integration patterns#
- Own your seams narrowly. Each
api/package declares exactly the collaborator subset its handlers touch (UserStore,SoftDeleteStore,Mailer,TokenIssuer, …) as local interfaces. The core repository or service satisfies them structurally wherever the method sets already match. - Core adapters live in
internal/modules— the only layer that may import both sides. Intent-named recorders (AuthNotificationRecorder), null-type conversion (SoftDeleteStore), policy translation (UserCredentialPolicy): formatting and translation live in the adapter so the module never imports repository or database types. - Sentinels translate at the seam. Handlers match module-domain errors
with
errors.Is; when a seam bridges two error vocabularies, mirror the message and pin it with a test asserting byte-equality. - Envelopes are byte contracts. All handlers write through
kernel/httpx; the compat suite diffs both writers, so any drift fails a test before a client ever sees it. - Manifests validate centrally.
Manifest()returns your data;AdminExtensionManifest.Validate()(labels present, paths absolute, no duplicate paths) is the shared check — never copy it per module.
Gating your module#
- Depguard. Nested modules (including their tests) must not import
lesstruct/internal— enforced mechanically bymake depguardvia the compiler’s own dep graph. Generate testify mocks withmake mock(mockery); prefer small hand-written recording stubs for narrow seams where a full mock is heavier than the contract. - Coverage. The domain layer carries a 100% statement bar; empty function bodies do not count. Unreachable defensive branches are documented per-branch exceptions, not faked tests.
- Test conventions. Table-driven suites with
namefirst andwantErrwhere errors return;ttloop variable;requirefor setup failures,assertfor the rest; factory functions over struct literals where they exist. Handler tests attach identity with a test-local principal helper mirroring what the auth middleware populates. - Conformance fixtures.
kernel/conformanceships the author-facing checks:CheckAuthorizer(+AuthorizerFixture) for capability resolvers,CheckAdminExtension(+ error-formValidateAdminExtensionfor non-testify harnesses) for manifest providers. Run the ones your module’s contracts touch. - Migrations. Implement
kernel.Migratorfor versioned tables (schema_migrations_<module>); idempotentCREATE TABLE IF NOT EXISTSso pre-module installs upgrade cleanly. They run on every compiled-in driver — includingnosqlitebuilds, which exclude sqlite entirely.
Composition-root wiring#
internal/app/app.go (Run) is the default composition: services are
built, modules adopt them via options, candidates accumulate,
ApplyDisabled prunes the [modules] disabled list (warn on drops and
unknown names), kept modules register before InitAll, migrations run,
handlers mount. (The blueprint calls a fixed module list compiled into one
binary an edition — that word lives here and in history only.)
Product-level mains stay thin (main.go calls Run); build variants like
-tags nosqlite are advanced compile options, not separate products.
Headless stays a config of the default composition ([headless] suppresses
public-surface mounting only) — never the disabled list.
Ship checklist#
- Layout matches the template;
go.modrequires + replaces kernel. Requires()is the cascade you intend; listed inSUBMODULES.- Handlers write through
kernel/httpx; envelope parity covered. - Manifest validates; conformance fixture adopted in the module test.
- Domain coverage at bar; depguard clean; lint + modernize clean.
README.mdnames the seams;docs/project-context.mdgains the entry.
Building and installing third-party modules#
Everything above applies to third parties too — the template, the seams, the gates. The honest question is integration: how does your module get into a running Lesstruct?
What a module can contribute#
A module is not limited to one thing. Through the kernel capability interfaces it can contribute any combination of:
| Surface | Contract | Contributes |
|---|---|---|
| Protected routes | RouteProvider | Authenticated HTTP endpoints mounted in the admin/API group. |
| Public routes | PublicRouteProvider | Anonymous HTTP endpoints (public API, SSR pages). |
| Themes | ThemeProvider | Embedded layouts, CSS, template funcs, site-root files for the SSR engine. |
| Admin UI | AdminExtensionProvider | Manifest entries the admin shell renders: menu, settings, editor extensions. |
| Post/content types | ContentTypeProvider | Registered types beyond post/page. |
| Capabilities | RoleProvider | Capability strings for the permission registry. |
| Migrations | Migrator | Versioned schema_migrations_<module> tables on every compiled-in driver. |
Beyond the contracts, a module owns its domain/ services and api/
handlers exactly like the first-party exemplars.
Tier 1 vs Tier 2: do you need to recompile?#
| Tier 1 — Go module | Tier 2 — WASM plugin | |
|---|---|---|
| Ships as | Source, compiled into the binary | A .wasm file dropped into plugins/ |
| Recompile needed | Yes — it is part of the binary | No — loaded at startup, hot-reloaded in DEV_MODE |
| Reach | Full: routes, themes, admin UI, migrations, storage | Event hooks (before_save, after_publish, …) via host functions |
| Sandboxing | None — trusted code | Yes — wazero runtime, capability manifest |
| Right for | Product forks, internal distributions, deep integrations | Third-party runtime extensions, optional add-ons |
Installing a Tier 1 module (the fork/build path)#
There is no runtime installation for Go modules: you compile your own Lesstruct. The composition root is deliberately a boring, machine-writable list, so this is four steps in a fork of the repository:
modules/<yourname>/— write the module per this guide (or copy the closest first-party exemplar and rename). Because you are inside the same module tree,internal/(the composition root, adapters) stays importable to you.- Add it to
SUBMODULESin the rootMakefileso the gates cover it. - Register it in
internal/app/app.go: build it from options after its dependencies exist and append it to the candidates list —InitAll, migrations, the disabled cascade, and the admin manifest pick it up unchanged. - Build your binary:
go build .or your own main package callingapp.Run()under any build constraints.
Nothing else changes: routes mount through your declared providers, the
admin manifest lists your entries, [modules] disabled = ["<yourname>"]
prunes you like every other module, and make test runs your suite.
Shipping a module to other people’s forks is the same mechanics with a
different diffusion: publish the source, they perform steps 1–3. A
lesstruct mod get-style installer that generates the composition
automatically is a known post-v1 direction — the boring-list design above is
what keeps that door open.
The kernel is the stability contract#
Everything your module compiles against lives in kernel/
(kernel/vX.Y.Z), versioned independently —
kernel-versioning.md is the promise your build can
rely on, and the kernel is the map of what you are building on.