Module Development

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):

1
2
3
4
5
6
7
8
9
modules/<name>/
  go.mod            # module github.com/aristorinjuang/lesstruct/modules/<name>
                    # requires kernel, replaces it at ../../kernel for local dev
  module.go         # kernel.Module: options, Requires, handler accessors
  domain/           # services over narrow seam interfaces; 100% coverage bar
  api/              # HTTP handlers + seams.go; writes through kernel/httpx
  admin/            # Manifest() + validation via kernel (see below)
  testutil/         # NewModule()/Kernel() fixture for module tests
  README.md         # what the module owns and its seams

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#

ContractUse it when…
kernel.Module / kernel.Kernel / Registry + InitAllAlways — the lifecycle every module implements.
Capabilities[T]Discovering sibling modules instead of a service locator (theme providers, migrators, route providers, extension manifests).
Principal + PrincipalFromContext / ContextWithPrincipalReading 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 / PublicRouteProviderMounting 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, MigratorContributing post types, capabilities, themes, admin UI, or versioned migrations.
Logger (nil degrades to discard)All module logging.
PruneNever 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 by make depguard via the compiler’s own dep graph. Generate testify mocks with make 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 name first and wantErr where errors return; tt loop variable; require for setup failures, assert for 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/conformance ships the author-facing checks: CheckAuthorizer (+ AuthorizerFixture) for capability resolvers, CheckAdminExtension (+ error-form ValidateAdminExtension for non-testify harnesses) for manifest providers. Run the ones your module’s contracts touch.
  • Migrations. Implement kernel.Migrator for versioned tables (schema_migrations_<module>); idempotent CREATE TABLE IF NOT EXISTS so pre-module installs upgrade cleanly. They run on every compiled-in driver — including nosqlite builds, 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#

  1. Layout matches the template; go.mod requires + replaces kernel.
  2. Requires() is the cascade you intend; listed in SUBMODULES.
  3. Handlers write through kernel/httpx; envelope parity covered.
  4. Manifest validates; conformance fixture adopted in the module test.
  5. Domain coverage at bar; depguard clean; lint + modernize clean.
  6. README.md names the seams; docs/project-context.md gains 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:

SurfaceContractContributes
Protected routesRouteProviderAuthenticated HTTP endpoints mounted in the admin/API group.
Public routesPublicRouteProviderAnonymous HTTP endpoints (public API, SSR pages).
ThemesThemeProviderEmbedded layouts, CSS, template funcs, site-root files for the SSR engine.
Admin UIAdminExtensionProviderManifest entries the admin shell renders: menu, settings, editor extensions.
Post/content typesContentTypeProviderRegistered types beyond post/page.
CapabilitiesRoleProviderCapability strings for the permission registry.
MigrationsMigratorVersioned 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 moduleTier 2 — WASM plugin
Ships asSource, compiled into the binaryA .wasm file dropped into plugins/
Recompile neededYes — it is part of the binaryNo — loaded at startup, hot-reloaded in DEV_MODE
ReachFull: routes, themes, admin UI, migrations, storageEvent hooks (before_save, after_publish, …) via host functions
SandboxingNone — trusted codeYes — wazero runtime, capability manifest
Right forProduct forks, internal distributions, deep integrationsThird-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:

  1. 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.
  2. Add it to SUBMODULES in the root Makefile so the gates cover it.
  3. 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.
  4. Build your binary: go build . or your own main package calling app.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.