Skip to content
SHC Docs

Package Architecture

Verified against HEAD (DI composition root, scope collapse, ent ORM, reqctx extraction) — 2026-07-03.

This document describes the package organization in SHC. Go’s package system naturally prevents circular dependencies through compile-time checks, so the focus here is on logical layering and dependency direction.

  1. Overview
  2. Package Layers
  3. Layer Rules
  4. Adding New Packages
  5. Common Patterns

All imports in SHC flow downward only — a package in Layer N can only import from layers 0 through N-1. Go enforces this at compile time; circular imports are a compile error.

Layer 0 (core/scope, core/coded, core/reqctx)
Layer 1 (core/render, core/log)
Layer 2 (core/config)
Layer 3 (core/db, core/entclient)
Layer 4 (modules: shell, events, docker, app, backup, ...)
Layer 5 (subsystems/lifecycle + subsystems/impl)
Layer 6 (daemon composition root: daemon)

The composition root (daemon/, ce module; this repo’s daemon/wiring layers the ee wiring on top) is what makes the direction work in practice: subsystems/impl imports every module, so the inverse can never hold. Module services are constructed in dependency order in daemon/composition.go and handed to consumers via constructor injection rather than package-level singletons.


Packages: core/scope, core/coded, core/reqctx

Dependencies: Standard library only (no internal imports)

Contains:

  • scope/: the single SHC scope ladder
    • Scope enum: Global, Tenant, Environment, Stack, Deployment
    • Ctx struct (Tenant, Stack, Environment)
    • Cascade(c Ctx) []Scope — narrowest-first precedence list, always ending in Global
    • Classify(c Ctx) Scope — the single most-specific tier for c
  • coded/: coded errors
    • CodedError struct (Error(), Unwrap(), Is(), WithDetails())
    • New(code, template string, kwargs map[string]any) *CodedError
    • Wrap(ce *CodedError, cause error) *CodedError, HasCode(err, code), Detail(err)
    • All X####CCCC error codes flow through this package
  • reqctx/: request-scoped identity ctx values (tenant, stack, environment)
    • WithTenant/WithStack/WithEnvironment(ctx, v) setters, Tenant/Stack/Environment(ctx) getters
    • The contract: request-scoped resolvers high in the stack write, low layers read (core/log enrichment, core/otel span attrs, metrics labels, auth scope resolution) — keeping the keys in a leaf is what lets core packages read them without importing a module (enforced by the core-imports-modules fitness check). NOTE: the production write side is not wired up yet — auth.WithStackEnvFromChi exists but is mounted nowhere, and tenant resolution still runs on modules/tenant’s separate private key — so today the readers see empty values on real requests; unifying the writers onto reqctx is owed work

Purpose: Foundational, dependency-free packages every other package can safely import. scope is deliberately import-free so both core/* and modules/* can depend on it without forming a cycle; it is the one authority for the config and vault cascades.

Packages: core/render, core/log

Dependencies: Layer 0 only (log also imports its sibling render for the shared voice primitives)

Contains:

  • render/: the shared output/voice primitives (Result, Warning, Hint, Print, mode resolution) the output contract is built on
  • log/: structured logging on top of slog
    • Event(ctx, code, msgTmpl string, payload map[string]any) error — emits a V######…/A######… event (audit codes start with A); also drives the audit chain and event bus when wired
    • RegisterEvent(code, msgTmpl string) *EventClass + EventCls(ctx, cls *EventClass, payload P) error — the typed event-class form (P = map[string]any)
    • Error(ctx, ce *coded.CodedError, cause error), Warn(ctx, ce *coded.CodedError), Coded(ctx, ce, cause) (severity-routes on the code class)
    • Context helpers: WithAttrs(ctx, ...slog.Attr), With(ctx, key, value), FromCtx(ctx) *slog.Logger
    • wiring.go lets the daemon attach the audit writer, event bus, and notifier

Purpose: Core logging/eventing service. It depends only on coded, render, and reqctx (ctx enrichment), and exposes seams the higher layers wire into (audit writer, event bus, notifier) so it never has to import them. Sitting this low is what lets every other core package (config, db, api) log through the typed pipeline directly.

Packages: core/config

Dependencies: Layers 0-1 (logs its coded tokenExec/embedded-ref failures via core/log)

Contains:

  • Config struct for configuration management
  • Load(ctx context.Context, opts LoadOpts) (*Config, error) — builds config from disk + env + the config DB table
  • LoadOpts for controlling sources, plus the file/DB reload watchers (watch.go, reload_db_test.go)

Purpose: Centralized configuration. Config is loaded into a *Config value and threaded to consumers — there is no package-level Get() singleton.

Packages: core/db, core/entclient (the rqlite driver overlay lives in this repo’s core/db/rqlitedriver)

Dependencies: Layers 0-2

Contains:

  • db/: the *sql.DB pool + lifecycle for the sqlite/rqlite engines (driver.go, pool.go, migrate.go migrations, fk.go, maintenance.go)
  • entclient/: the process-wide *ent.Client registry — modules call Get() at method-call time (the EntClient subsystem starts late, at lifecycle priority 6, so a value captured at construction would be nil). SetClient / ServiceOptions injection are the test paths

Purpose: Database access. SHC uses the ent ORM (entgo.io/ent); there is no GORM and no separate models package. entclient lives under core/ rather than subsystems/impl so any module can import it without a cycle.

Packages: modules/*

Dependencies: Layers 0-3 (and sibling modules where it doesn’t cycle)

Contains:

  • Feature modules: app, backup, config, audit, auth, stack, vault, events, docker, shell, dns, ca, cluster, and many more
  • Each module typically has: register.go, router.go, service.go (defining type Service), errors.go, types.go, and a commands/ subpackage for CLI wiring
  • Infrastructure-flavored modules also live here rather than under subsystems/:
    • events/: event bus types, handler registration (OnTask/OnRpc/OnCron), broadcaster, replay
    • docker/: Docker client, event tap, service labels/prune
    • shell/: command execution (Run-style API, proxy/daemon session)

Purpose: Business logic organized by feature domain. Services are plain constructable types; they are wired together in the composition root, not looked up from globals.

Packages: subsystems/lifecycle, subsystems/impl

Dependencies: Layers 0-4 (impl imports every module)

Contains:

  • lifecycle/: the orchestration contract
    • Subsystem interface, Resolver, topological start/stop ordering (topo.go), the bridge.go adapter
  • impl/: the concrete long-running subsystems that power the daemon, e.g.
    • event_bus.go, queue.go, sse_broker.go
    • docker_events.go, docker_publisher.go
    • ent_client.go + migrations.go (the ent client / schema subsystem)
    • reconcilers, publishers, leader hub, internal API, etc.

Purpose: The daemon’s running infrastructure, ordered and supervised by lifecycle. Because impl may import any module, modules must never import impl.

Packages: daemon

Dependencies: All lower layers (0-5)

Contains:

  • composition.go: the Services struct — the single owner of the daemon’s constructed module services, built in dependency order and handed out via constructor injection (registry.Deps.Services for HTTP/CLI builders; the lifecycle Resolver for subsystems). This is the mature-Go replacement for the per-module GetRuntimeService/SetRuntimeService package singletons; during the DI migration it dual-writes to the legacy singletons behind a bridge.
  • daemon.go: daemon bootstrap, loadConfig, DB-config reload subscription, lifecycle wiring

Purpose: Wire everything together. The two binaries (cmd/shc and cmd/shcd) are thin entry points over this.


From LayerCan Import From
Layer 0stdlib only
Layer 1Layer 0
Layer 2Layers 0-1
Layer 3Layers 0-2
Layer 4Layers 0-3 (+ sibling modules, no cycle)
Layer 5Layers 0-4 (impl imports every module)
Layer 6Layers 0-5
  1. cmd/shc/main.go and cmd/shcd/main.go: thin entry points over daemon
  2. Intra-layer imports: packages in the same layer can import each other if it doesn’t create a cycle (common among modules)
  3. Interface-based decoupling: lower layers (e.g. core/log) expose wiring seams that higher layers fill in, so the dependency never points upward. Seams are for genuine service dependencies (event bus, notifier, audit writer) — shared values like the request ctx keys belong in a Layer-0 leaf (core/reqctx) instead of a seam
  4. core/entclient placement: lives under core/ (not subsystems/impl) precisely so any module can import it without a cycle

  1. Identify the appropriate layer based on dependencies
  2. Create the package under the appropriate directory
  3. Ensure all imports follow layer rules
  4. go build ./... will catch circular imports

If your package has unique dependency requirements:

  1. Determine which layer it belongs to based on what it needs to import
  2. Create the package in the appropriate directory structure
  3. Document the package’s purpose and dependencies
  • Prefer lower layers: Put packages as low as possible to maximize reuse
  • Avoid upward dependencies: Never import from a higher layer
  • Use seams/interfaces: Define the contract in the lower layer, fill it in from a higher layer (see core/log wiring)
  • Keep packages focused: Each package should have a single responsibility

import "gitlab.com/bitspur/selfhosted-cloud/ce/core/scope"
func resolve(tenant, stack, env string) {
c := scope.Ctx{Tenant: tenant, Stack: stack, Environment: env}
for _, tier := range scope.Cascade(c) { // Deployment, Stack, Environment, Tenant, Global
// walk the cascade; first hit wins
_ = tier
}
owner := scope.Classify(c) // the single tier a row written under c belongs to
_ = owner
}
import "gitlab.com/bitspur/selfhosted-cloud/ce/core/log"
func doSomething(ctx context.Context) error {
// log.Event drives the audit chain and event bus when the daemon
// has wired them; codes are "V"/"A" + 6 digits + 6+ uppercase letters.
return log.Event(ctx, "A200100STKINS", "installed stack {stack}", map[string]any{
"stack": "postgres",
})
}
import "gitlab.com/bitspur/selfhosted-cloud/ce/modules/events"
func init() {
events.OnTask(
"A200100STKINS",
handleInstall,
nil, // kwargs
events.DispatchSingle, // DispatchMode
events.HandlerOptions{},
)
}
func handleInstall(ctx context.Context, e *events.Event) error {
// e.Code, e.Payload, e.TenantName, e.StackName, ...
return nil
}
import "gitlab.com/bitspur/selfhosted-cloud/ce/core/coded"
func mustExist(name string) error {
if name == "" {
return coded.New("X4000STCK", "stack {name} not found", map[string]any{"name": name})
}
return nil
}

When a lower layer needs behavior that lives higher up, the lower layer defines the seam and the daemon fills it in:

// In core/log (Layer 1): the package exposes a setter; it never
// imports the event bus.
// log.SetEventBus(bus) // see core/log/wiring.go
// In daemon/composition.go (Layer 6): the composition root
// constructs the bus and wires it into log, so the dependency points
// downward only.

LayerPackagesPurpose
0core/scope, core/coded, core/reqctxFoundation: scope ladder + coded errors + request ctx values, no internal deps
1core/render, core/logOutput primitives + structured logging/event emission
2core/configConfiguration loaded into *Config (no singleton)
3core/db, core/entclientsqlite/rqlite *sql.DB pool + ent *ent.Client registry
4modules/*Business logic + infra modules (events, docker, shell, …)
5subsystems/lifecycle, subsystems/implSupervised running subsystems
6daemonComposition root that wires everything

Go’s compiler enforces acyclic imports. This layering provides logical organization and a clear, downward-only dependency direction, anchored by the daemon composition root.