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.
Table of Contents
Section titled “Table of Contents”Overview
Section titled “Overview”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.
Package Layers
Section titled “Package Layers”Layer 0: Foundation
Section titled “Layer 0: Foundation”Packages: core/scope, core/coded, core/reqctx
Dependencies: Standard library only (no internal imports)
Contains:
scope/: the single SHC scope ladderScopeenum:Global,Tenant,Environment,Stack,DeploymentCtxstruct (Tenant,Stack,Environment)Cascade(c Ctx) []Scope— narrowest-first precedence list, always ending inGlobalClassify(c Ctx) Scope— the single most-specific tier forc
coded/: coded errorsCodedErrorstruct (Error(),Unwrap(),Is(),WithDetails())New(code, template string, kwargs map[string]any) *CodedErrorWrap(ce *CodedError, cause error) *CodedError,HasCode(err, code),Detail(err)- All
X####CCCCerror 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/logenrichment,core/otelspan 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 thecore-imports-modulesfitness check). NOTE: the production write side is not wired up yet —auth.WithStackEnvFromChiexists but is mounted nowhere, and tenant resolution still runs onmodules/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.
Layer 1: Output & Logging
Section titled “Layer 1: Output & Logging”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 onlog/: structured logging on top ofslogEvent(ctx, code, msgTmpl string, payload map[string]any) error— emits aV######…/A######…event (audit codes start withA); also drives the audit chain and event bus when wiredRegisterEvent(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.golets 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.
Layer 2: Configuration
Section titled “Layer 2: Configuration”Packages: core/config
Dependencies: Layers 0-1 (logs its coded tokenExec/embedded-ref failures via core/log)
Contains:
Configstruct for configuration managementLoad(ctx context.Context, opts LoadOpts) (*Config, error)— builds config from disk + env + theconfigDB tableLoadOptsfor 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.
Layer 3: Persistence
Section titled “Layer 3: Persistence”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.DBpool + lifecycle for the sqlite/rqlite engines (driver.go,pool.go,migrate.gomigrations,fk.go,maintenance.go)entclient/: the process-wide*ent.Clientregistry — modules callGet()at method-call time (theEntClientsubsystem starts late, at lifecycle priority 6, so a value captured at construction would be nil).SetClient/ServiceOptionsinjection 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.
Layer 4: Modules
Section titled “Layer 4: Modules”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(definingtype Service),errors.go,types.go, and acommands/subpackage for CLI wiring - Infrastructure-flavored modules also live here rather than under
subsystems/:events/: event bus types, handler registration (OnTask/OnRpc/OnCron), broadcaster, replaydocker/: Docker client, event tap, service labels/pruneshell/: 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.
Layer 5: Subsystems
Section titled “Layer 5: Subsystems”Packages: subsystems/lifecycle, subsystems/impl
Dependencies: Layers 0-4 (impl imports every module)
Contains:
lifecycle/: the orchestration contractSubsysteminterface,Resolver, topological start/stop ordering (topo.go), thebridge.goadapter
impl/: the concrete long-running subsystems that power the daemon, e.g.event_bus.go,queue.go,sse_broker.godocker_events.go,docker_publisher.goent_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.
Layer 6: Composition Root
Section titled “Layer 6: Composition Root”Packages: daemon
Dependencies: All lower layers (0-5)
Contains:
composition.go: theServicesstruct — the single owner of the daemon’s constructed module services, built in dependency order and handed out via constructor injection (registry.Deps.Servicesfor HTTP/CLI builders; the lifecycleResolverfor subsystems). This is the mature-Go replacement for the per-moduleGetRuntimeService/SetRuntimeServicepackage 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.
Layer Rules
Section titled “Layer Rules”| From Layer | Can Import From |
|---|---|
| Layer 0 | stdlib only |
| Layer 1 | Layer 0 |
| Layer 2 | Layers 0-1 |
| Layer 3 | Layers 0-2 |
| Layer 4 | Layers 0-3 (+ sibling modules, no cycle) |
| Layer 5 | Layers 0-4 (impl imports every module) |
| Layer 6 | Layers 0-5 |
Special Cases
Section titled “Special Cases”cmd/shc/main.goandcmd/shcd/main.go: thin entry points overdaemon- Intra-layer imports: packages in the same layer can import each other if it doesn’t create a cycle (common among modules)
- 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 core/entclientplacement: lives undercore/(notsubsystems/impl) precisely so any module can import it without a cycle
Adding New Packages
Section titled “Adding New Packages”To an Existing Layer
Section titled “To an Existing Layer”- Identify the appropriate layer based on dependencies
- Create the package under the appropriate directory
- Ensure all imports follow layer rules
go build ./...will catch circular imports
Creating a New Package
Section titled “Creating a New Package”If your package has unique dependency requirements:
- Determine which layer it belongs to based on what it needs to import
- Create the package in the appropriate directory structure
- Document the package’s purpose and dependencies
Guidelines
Section titled “Guidelines”- 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/logwiring) - Keep packages focused: Each package should have a single responsibility
Common Patterns
Section titled “Common Patterns”Working with the Scope Ladder
Section titled “Working with the Scope Ladder”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}Emitting an Event from Log
Section titled “Emitting an Event from Log”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", })}Registering an Event Handler
Section titled “Registering an Event Handler”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}Returning a Coded Error
Section titled “Returning a Coded Error”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}Interface-Based Decoupling (wiring seams)
Section titled “Interface-Based Decoupling (wiring seams)”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.Summary
Section titled “Summary”| Layer | Packages | Purpose |
|---|---|---|
| 0 | core/scope, core/coded, core/reqctx | Foundation: scope ladder + coded errors + request ctx values, no internal deps |
| 1 | core/render, core/log | Output primitives + structured logging/event emission |
| 2 | core/config | Configuration loaded into *Config (no singleton) |
| 3 | core/db, core/entclient | sqlite/rqlite *sql.DB pool + ent *ent.Client registry |
| 4 | modules/* | Business logic + infra modules (events, docker, shell, …) |
| 5 | subsystems/lifecycle, subsystems/impl | Supervised running subsystems |
| 6 | daemon | Composition 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.