Architecture¶
This document describes how hermes is put together, with enough precision that a
human contributor or a coding agent can add a new provider correctly without guessing. For a
usage-focused quick-start, see README.md. For integrating this module into your own
project end-to-end, see docs/INTEGRATION.md.
1. Module overview¶
hermes is a standalone, dependency-free Go module for outbound notification delivery.
It has four layers:
- Root package (
notify) —Message(the generic, provider-agnostic payload),Sender(the uniform dispatch interface every provider implements), and the provider registry (Register/New/RegisteredTypesinfactory.go) that ties everything below together. transport— the shared, SSRF-safe outbound HTTP primitive (transport.Wrapper) that every HTTP-based provider dispatches through: destination validation, retry/backoff, redirect re-validation, and request/response size caps.providers/*— one package per notification service (discord,slack,gotify,pushover,ntfy,telegram,webhook,email,webpush), each exposing a typedConfigstruct, aNew(...)constructor, and aClientimplementingnotify.Sender.providers/all— a blank-import bundle that registers every built-in provider package with the root registry in one line, for consumers who want zero-touch discovery.
The module never assumes anything about the host application. Every environment-specific concern —
the HTTP client, the SSRF policy, the SMTP transport, HTML templates — is a constructor-injected
interface supplied by the caller (transport.ClientFactory, transport.URLValidator,
email.Mailer, email.TemplateRenderer). This is why hermes must never import
anything from a host application's own module — see the non-negotiable design rule in
CLAUDE.md.
2. The Sender contract¶
type Sender interface {
Send(ctx context.Context, msg Message) error
}
Every provider package's Client implements this one method, and every implementation is expected
to:
- Respect
ctx— honor cancellation/deadlines rather than blocking indefinitely (the HTTP-based providers get this for free viatransport.Wrapper.Send, which threadsctxthroughhttp.NewRequestWithContext). - Wrap errors, never swallow them —
Sendreturns a descriptive,fmt.Errorf-wrapped error on failure; it never logs-and-returns-nil. - Never panic — a bad destination, a malformed template, or a provider's non-2xx response is
always a returned
error, never a panic. Panicking is reserved for the registry's own programmer-error checks (§5) — aSender.Sendcall is a runtime data-flow path, not a configuration-time assertion.
The "why": a host application typically fans one Message out to N configured destinations
([]notify.Sender). A uniform contract — same method, same error-vs-panic discipline, same
context-respecting behavior — lets that fan-out loop treat every provider identically, regardless
of whether it's an HTTP webhook or email:
for _, s := range senders {
if err := s.Send(ctx, msg); err != nil {
log.Printf("notify: %v", err)
}
}
3. Adding a new provider — step-by-step¶
3.1 File/package layout¶
providers/<name>/
<name>.go # Config, Client, New(cfg, w) / New(cfg), Send — the core implementation
<name>_test.go # table-driven Send tests, config validation tests
register.go # init()-time notify.Register("<name>", factory) — kept separate from
# <name>.go so registry plumbing doesn't clutter the core constructor logic
register_test.go # registration round-trip tests
<name> is lowercase, no underscores (webpush, not web_push) — see §3.6 on why this matters.
3.2 The Config struct convention¶
- An exported
Configstruct with exported fields, documented with doc comments. - HTTP-based providers include
TemplateandCustomTemplate stringfields, feeding the sharedproviders/internal/rendertemplate engine (Templateselects"minimal"/"detailed"/"custom";CustomTemplatesupplies the template string when"custom"). emailis the documented exception: itsConfighas noTemplate/CustomTemplatefields at all, because email doesn't dispatch a JSON payload — it composes an HTML body via a host-suppliedTemplateRendererinstead. Don't force every provider into the HTTP-shapedConfigconvention; follow what the provider's transport actually needs.webpushis the first provider whoseConfigmixes two conceptually distinct field groups in one flat struct: application-wide VAPID identity (shared across every subscription) and one subscriber'sPushSubscriptiondestination (per-recipient). It's still one flat exported struct fed through the sameNew(cfg, w)constructor shape as every other provider — flagged here only so a future reader doesn't assume everyConfigfield is per-recipient.
3.3 The New constructor convention¶
- HTTP-based providers:
func New(cfg Config, w *transport.Wrapper) *Client—wis the shared dispatch primitive (seetransport.NewWrapper), constructed once per host application and threaded into every HTTP-based provider'sNew. Never construct your own*http.Clientinside a provider package; always dispatch through the injected*transport.Wrapper. emailis the one exception:func New(cfg Config) *Client— no*transport.Wrapperparameter, because email never dials HTTP itself (it hands off tocfg.Mailer).
3.4 Compile-time interface assertion¶
Every provider package includes, right after its Client type:
var _ notify.Sender = (*Client)(nil)
This is required, not optional — it turns "Client stopped implementing Sender" into a build
failure at the point of the mistake, rather than a runtime type-assertion failure somewhere else.
3.5 The Register/factory pattern¶
register.go's exact template (HTTP-based providers — see providers/discord/register.go for the
canonical example):
package <name>
import (
"fmt"
notify "github.com/Wikid82/Hermes"
"github.com/Wikid82/Hermes/providers/internal/regconfig"
"github.com/Wikid82/Hermes/transport"
)
func init() {
notify.Register("<name>", func(config map[string]any) (notify.Sender, error) {
w, ok := config["transport"].(*transport.Wrapper)
if !ok || w == nil {
return nil, fmt.Errorf(`<name>: config["transport"] must be a non-nil *transport.Wrapper`)
}
cfg := Config{
SomeField: regconfig.StringField(config, "some_field"),
// ... one line per Config field
}
return New(cfg, w), nil
})
}
Key/type conventions:
"transport"is reserved for the shared*transport.Wrapper, required by every HTTP-based provider's factory.- Every other
Configfield is expected under its lowercase snake_case field name — e.g.Config.WebhookURL→config["webhook_url"],Config.APIToken→config["api_token"]. - Use the shared
providers/internal/regconfighelpers (StringField,StringSliceField) to extract plain string/[]stringvalues — don't hand-roll the same type-assert-with-default boilerplate in everyregister.go. - Behavioral/non-serializable values (interfaces, closures — see
providers/email/register.go) are type-asserted directly out of the map under their own well-known key; there is no generic extraction helper for these because they're inherently provider-specific. - Factories must return an error, never panic, on bad/missing config. A missing
"transport"key or a config with a required field wrong-typed is a caller mistake at runtime (e.g. Charon's adapter built the map wrong) — returnfmt.Errorf, notpanic. Contrast withRegisteritself (§5), which panics on a nil/duplicate registration — that's a programmer mistake, caught once atinit()time.
3.6 Adding to providers/all¶
Add one blank-import line to providers/all/all.go:
_ "github.com/Wikid82/Hermes/providers/<name>"
This step has no compiler safety net. A new provider that registers itself correctly but isn't
added to providers/all still works fine for a consumer that hand-picks
import _ "…/providers/<name>" directly — it just silently isn't part of the "one import gets
everything" bundle. providers/all/all_test.go has a TestAll_RegistersEveryBuiltInProvider test
asserting len(notify.RegisteredTypes()) == wantProviderCount — bump wantProviderCount in that
test whenever you add a provider, or the test fails loudly instead of silently missing your
addition.
3.7 Naming conventions¶
Provider package names are lowercase, no underscores, and must match the Register key exactly
(package webpush registers as notify.Register("webpush", ...), not "web_push" or "WebPush").
This matters because Register panics on a duplicate name (§5) — two providers accidentally
choosing the same string is a same-binary collision, and consistent naming is the only thing
preventing an easy, avoidable one.
3.8 Test expectations¶
Every provider package's tests should cover, mirroring the existing nine providers' patterns:
- Table-driven
Sendtests against a faketransport.Wrapper, built via an injectedClientFactoryreturning acapturingRoundTripper(see anyproviders/*/*_test.gofor the pattern) — no real network calls. - Config validation error-path tests — e.g. a missing required field returns an error before any dispatch is attempted.
- ≥85% coverage per package, per this repo's
CLAUDE.mdcoverage bar. - A registration round-trip test (in
register_test.go) assertingnotify.New("<name>", validConfig)succeeds and returns a workingSender(behaviorally equivalent to the typed constructor). - A missing-required-key test asserting a config missing
"transport"(or, for email,"mailer") returns anerror, not a panic.
3.9 Config validation/error handling conventions¶
The registry's factory layer validates structurally (is the right type present under each
expected key?) and returns a fmt.Errorf-wrapped error naming exactly which key/type was expected.
The underlying Config-consuming New/Send still perform their own semantic validation
exactly as they do today (e.g. Discord's webhook-host allowlist, Slack's URL-shape regex, Pushover's
required user key/API token). The registry layer is a validation step in front of, not instead
of, each provider's existing validation.
4. The registry internals¶
Register, New, and RegisteredTypes live at the module root (factory.go, package notify) —
not a subpackage — because every provider package already imports the root notify package (for
Sender/Message), so this is the only placement with zero new import edges. Behavior:
Register(name string, factory Factory)storesfactoryunderstrings.ToLower(name)in async.RWMutex-guarded map. It panics iffactoryis nil, ifnameis empty, or ifnameis already registered — mirroringdatabase/sql.Registerexactly. This is a programmer error caught atinit()time (a build-time-discoverable defect, e.g. two packages both registering"webhook"), not a runtime condition — panicking fails the program immediately and loudly rather than silently shadowing one provider with another.New(name string, config map[string]any) (Sender, error)looks up the registered factory (case-insensitively) and invokes it. Returns an error — never panics — ifnameisn't registered; the opposite discipline fromRegister, because "provider type X isn't registered" is a legitimate runtime condition (a host forgot to blank-import the package, or a config references a typo'd/future type) that calling code must be able to handle gracefully.RegisteredTypes() []stringreturns the sorted list of currently registered names — introspection for a host that wants to validate a config value or populate a UI dropdown against exactly what's compiled in.- The
sync.RWMutexexists because registrations happen atinit()time (effectively single-threaded, beforemainruns) butNew/RegisteredTypesmay be called concurrently from a host application's request-handling goroutines.
The registry is an additive convenience/discovery layer — not a replacement for the typed
constructors. discord.New(discord.Config{...}, wrapper) remains fully supported, fully
type-safe, and is the recommended path for a caller that doesn't need runtime discovery. notify.New
trades compile-time type safety at this one boundary (a caller can put a string under a key a
factory expects to be a *transport.Wrapper and won't find out until New returns an error) for a
single uniform mechanism that also accommodates providers like email whose Config can't
round-trip through a plain-data boundary (JSON) at all.
5. Versioning note¶
Under this module's semver policy:
- Adding a new provider package (a new
providers/<name>with its ownRegistercall) is afeat:/minor-version change — it's additive; no existing provider's API surface changes. - Changing
Register,New,Factory, orRegisteredTypes's signature is a breaking/major -version change — every provider package'sregister.goand every host application callingnotify.Newdirectly depends on these exact shapes. - Changing an existing provider's typed
Config/New/Sendsignature is also breaking/major, unchanged from this module's policy before the registry existed — the registry doesn't relax this.
This is stated explicitly here so a contributor (or a coding agent) doesn't have to guess at PR/tag time.
6. Repository layout and documentation site¶
Beyond the Go packages above, the repository carries these non-library directories:
docs-site/— the published documentation site, built with Zensical (pinned indocs-site/requirements.txt; Python is used only for this docs tooling, never by the Go module).docs-site/zensical.tomldefines the nav (Overview, Integration, Architecture).docs-site/scripts/sync-docs.shcopiesREADME.md,docs/INTEGRATION.md, andARCHITECTURE.mdinto the git-ignoreddocs-site/docs/and rewrites cross-file links; the originals are never edited, so there is no duplicated hand-maintained content..github/workflows/docs.yml— builds the site withzensical build --strictand deploys it to GitHub Pages on pushes tomainthat touch the docs sources.