Cross-Module Access Patterns
Verified against HEAD (DI composition root, scope collapse, ent ORM) — 2026-06-22.
This document describes the patterns for cross-module access in SHC, helping developers understand when and how modules can interact with each other.
Core Principles
Section titled “Core Principles”- Handlers should be thin - They handle HTTP concerns only and delegate to services
- Services encapsulate business logic - Including database queries for their domain
- Cross-module access goes through injected handles, not global lookups - Services are built once at the daemon composition root and handed to consumers; prefer the injected handle (or an existing module API) over re-importing a sibling’s internals
- Commands use
daemonclient- CLI commands call API endpoints over the daemon’s socket, not services directly - Some cross-module coupling is acceptable - When services need each other for complex operations, the dependency is inverted through a consumer-defined interface
Canonical Pattern: the DI Composition Root
Section titled “Canonical Pattern: the DI Composition Root”The single owner of the daemon’s constructed module services is daemon.Services
(daemon/composition.go) — the composition root. Services are built
there in dependency order and handed to consumers by constructor injection,
replacing the older per-module GetRuntimeService / SetRuntimeService package
singletons (the service-locator / “reach-in” pattern).
There are two injection surfaces:
- HTTP / CLI builders read the root off
registry.Deps.Services(core/registry/types.go). That field is typedany(type-erased to avoid aregistry→daemonimport cycle). - Subsystems receive their dependencies through the lifecycle
Resolverinstead (per-call resolvers wired at boot).
How a consumer reads the root — a consumer-defined interface
Section titled “How a consumer reads the root — a consumer-defined interface”A router or command builder type-asserts the small accessor interface it
defines itself against d.Services. Defining the interface on the consumer
side — rather than importing the daemon — is what keeps the dependency inverted
and cycle-free. This is the template every module router follows.
// In modules/audit/register.go// auditServices is the accessor the audit router needs from the daemon// composition root (registry.Deps.Services). The daemon's *Services// implements it. Defining the interface HERE (consumer-side) keeps the// dependency inverted and cycle-free.type auditServices interface { AuditService() *Service}
// resolveService prefers the injected composition root, falling back to// the legacy GetRuntimeService singleton (the migration bridge) when no// root is wired — e.g. a unit test that builds the router directly.func resolveService(d registry.Deps) *Service { if rd, ok := d.Services.(auditServices); ok { if s := rd.AuditService(); s != nil { return s } } return GetRuntimeService()}Migration bridge. The root is built behind a dual-write bridge: as each service is moved onto
Servicesit is also published to its legacy package singleton, so consumers not yet converted keep resolving throughGetRuntimeService. Those bridge writes are deleted in the final cutover once every consumer reads the injected handle. New code should target the injected handle, notGetRuntimeService.
Per-call resolvers, not stored DB handles
Section titled “Per-call resolvers, not stored DB handles”Cross-module data dependencies are injected as per-call resolvers, not as a
shared *gorm.DB (or any database.GetDB()-style global — that helper does not
exist). The daemon hands a sibling module a closure that resolves the per-request
value on each invocation. The canonical example is the tenant scope:
// tenant.Resolve is the per-call "what tenant am I in right now?" helper// (modules/tenant/state.go). It walks the request ctx; it neither// needs nor touches any *Service singleton.func Resolve(ctx context.Context) string { ... }Modules that need the active tenant accept a func(ctx context.Context) string
resolver and call it per request, rather than reading a stored ctx.TenantName
field. (See the clone and stack service constructors below.) This is the
scope collapse: a single ctx-walking resolver replaces the old per-service
tenant field, and the scope tiers themselves live in core/scope
(Deployment = tenant + stack + environment).
Acceptable Cross-Module Patterns
Section titled “Acceptable Cross-Module Patterns”Pattern 1: Service constructed with a Providers bundle (consumer-defined seams)
Section titled “Pattern 1: Service constructed with a Providers bundle (consumer-defined seams)”When a service from one module needs functionality from siblings, it declares the
seams it needs as interfaces it owns and accepts them in a Providers bundle.
The daemon satisfies each seam with an adapter at wire time.
Example: clone.Service and its Providers
clone.NewService takes a Providers bundle plus a tenant resolver — it does
not take a *db:
// In modules/clone/service.gofunc NewService(p Providers, tenant TenantResolver) *Service { ... }
// TenantResolver is the per-request tenant scope the orchestrator runs under.type TenantResolver func(ctx context.Context) string// In modules/clone/types.go — the consumer-defined seams.type Providers struct { Stack StackProvider Node NodeProvider Backup BackupProvider Recovery RecoveryProvider Vault VaultProvider Volume VolumeProvider Filesystem FilesystemProvider Progress OperationProgress}The daemon builds these adapters in wireClone and calls
clone.NewService(providers, nil) (daemon/services.go). Because
wireClone runs before the sibling modules publish, the full provider set lands
later via Service.SetProviders (the Stack/Node seams resolve their
underlying services lazily on each call).
Pattern 2: Backup needs stack context — encapsulate the lookup with ent
Section titled “Pattern 2: Backup needs stack context — encapsulate the lookup with ent”When a service needs a row owned by another module, it keeps the lookup inside a
service method so the handler stays thin. Persistence is the ent ORM
(generated under shc/ent/..., a typed *ent.Client), not GORM. The stack row type is
app.StackModel (formerly DeploymentModel).
// In modules/backup/service.go — ent, not GORM.// The service holds a typed *ent.Client (s.liveEnt()); queries use the// generated predicates and ent.IsNotFound for the not-found check.rows, err := q. Order(ent.Desc(entbackup.FieldCreatedAt)). All(ctx)if ent.IsNotFound(err) { return nil, NewStackNotFoundError(stackName)}Backup retains a couple of
database/sql-style escape hatches alongside the typed*ent.Client, but ent is the query API for new code.
Pattern 3: Cross-module orchestration lives in the owning module’s router
Section titled “Pattern 3: Cross-module orchestration lives in the owning module’s router”A complex multi-step operation lives in the module that owns the orchestration and is exposed through that module’s own router — not by reaching a sibling service in from another module’s handler.
Example: stack move is served by the clone module
There is no app.HandleMoveStacks. Move is part of the clone orchestration:
clone.Service.Move(ctx, req) (single stack) and Service.MoveStacks (the
multi-stack CLI envelope) in modules/clone/service.go. The clone
router (modules/clone/router.go) mounts:
POST /api/method/shc.stack.clonePOST /api/method/shc.stack.move— multi-stack,MoveRequest → AppMoveResult, the publicshc moveenvelope; parsesname:newnametokens intoService.MoveStacksPOST /api/method/shc.stack.move_single—StackMoveRequest → MoveResult, dispatchesService.Movedirectly
The router resolves the service per request through a closure
(clone.SetServiceResolver), surfacing a typed coded error when the service is
not yet wired.
Pattern 4: Commands Use daemonclient (Preferred)
Section titled “Pattern 4: Commands Use daemonclient (Preferred)”CLI commands call API endpoints over the daemon’s socket via the
daemonclient package (core/daemonclient, package daemonclient),
not by importing services directly. This ensures:
- Consistent behavior between CLI and API
- Proper request/response validation
- Authentication and authorization applied uniformly
daemonclient exposes a *Client whose methods take a context.Context:
func (c *Client) Get(ctx context.Context, path string, params url.Values, out any) errorfunc (c *Client) Post(ctx context.Context, path string, params url.Values, body any, out any) errorCommand packages wrap this in thin local helpers (daemonGet, daemonPostJSON,
daemonPutJSON, daemonDelete) that resolve the daemon endpoint and attach auth.
Example: Node list command
// In modules/node/commands/list.go — CORRECT pattern.// Commands route through the API layer so CLI and API behave identically.import "gitlab.com/bitspur/selfhosted-cloud/ce/core/daemonclient"
if err := daemonGet(cmdCtx(cmd), "/api/method/shc.node.ls", nil, &resp); err != nil { return err}Anti-pattern to avoid:
// DON'T DO THIS in commands - direct service import.import "gitlab.com/bitspur/selfhosted-cloud/ce/modules/node"
svc := node.NewService(target) // node service is node-side work, not a CLI clientnodes, err := svc.ListAll()(Note: node.NewService(target NodeTarget, opts ...ServiceOption) constructs a
node-side worker that shells out against a target — it is not a DB-backed service
and takes no ctx, db.)
Reference Implementation
Section titled “Reference Implementation”The audit module (modules/audit/) is the smallest end-to-end
example of the consumer-defined-interface DI template; the node module
(modules/node/) shows the command/router/service split.
| File | Role | Pattern |
|---|---|---|
register.go | Module wiring | Declares the consumer-side accessor interface and resolveService(d) |
commands/*.go | CLI commands | Use daemonclient (via local daemonGet/daemonPostJSON helpers) |
router.go | API endpoints | Thin layer; resolves the *Service per request |
service.go | Business logic | Holds a typed *ent.Client; encapsulates queries |
types.go | Data models / seams | Request/response structs (validate: tags) and consumer-defined provider interfaces |
models.go | Persistence models | Row structs (e.g. app.StackModel) |
See inline comments in these files for detailed pattern documentation.
Documented Cross-Module Imports
Section titled “Documented Cross-Module Imports”The following cross-module imports are documented as acceptable exceptions. They are satisfied through consumer-defined interfaces / provider seams, not raw sibling-service reach-ins.
Currently Acceptable
Section titled “Currently Acceptable”| Source | Target | Reason |
|---|---|---|
clone router | clone.Service.Move / MoveStacks | Move/clone orchestration is owned by the clone module and served by its own router |
backup/service.go | app.StackModel (via ent) | Backup needs stack info for context |
backup/restore.go | app.StackModel (via ent) | Restore needs to update stack fingerprint |
clone providers | app stack service (StackProvider seam) | Clone uses stack install/uninstall + integration queries |
clone providers | cluster/node (NodeProvider seam) | Clone resolves nodes for cross-node move/volume work |
Candidates for Future Refactoring
Section titled “Candidates for Future Refactoring”The following cross-module dependencies work correctly but are catalogued for potential future tightening (e.g. narrower seams or an explicit API):
| Source | Target | Current Usage | Potential Refactor |
|---|---|---|---|
clone StackProvider | app.StackModel | Stack lookup for clone context | Could narrow to a read-only stack-info seam |
clone NodeProvider | cluster.NodeRepo | Node lookup for volume operations | Could expose a dedicated Node lookup API |
logs/service.go | app.StackModel | Stack lookup for log retrieval | Could use a Stack API carrying node info |
logs/service.go | node lookup | Node lookup for internal API connection | Could use extended Stack API response |
Note: Apply tighter seams incrementally as these modules are modified.
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”1. Direct Model Queries in Handlers
Section titled “1. Direct Model Queries in Handlers”Bad:
// In router.go - DON'T DO THISvar stack app.StackModelclient.StackModel.Query().Where(stackmodel.Name(name)).First(ctx) // raw query in the handlerservice.Create(&stack, request)Good:
// In router.go - DO THIS (the service encapsulates the lookup)service.CreateForStack(stackName, request)2. Commands Importing Services Directly
Section titled “2. Commands Importing Services Directly”Bad:
// In commands - DON'T DO THIS (there is no database.GetDB and no NewBackupService(ctx, db))import "gitlab.com/bitspur/selfhosted-cloud/ce/modules/backup"
svc := backup.NewService(...)result, err := svc.Create(...)Good:
// In commands - DO THISimport "gitlab.com/bitspur/selfhosted-cloud/ce/core/daemonclient"
var response BackupResponseerr := daemonPostJSON(cmdCtx(cmd), "/api/method/shc.backup.create", requestData, &response)3. Reaching a Sibling Service in via GetRuntimeService
Section titled “3. Reaching a Sibling Service in via GetRuntimeService”New code should read the injected handle from the composition root through a
consumer-defined interface (Pattern: DI Composition Root), not call a sibling’s
GetRuntimeService(). The package singletons are a temporary migration bridge
slated for deletion at the Stage C cutover.
4. Circular Service Dependencies
Section titled “4. Circular Service Dependencies”If services A and B both need each other, refactor to:
- Extract shared logic to a third service
- Use events/hooks for loose coupling
- Consider if the operations should be in the same module
5. Direct Cross-Module Model Writes
Section titled “5. Direct Cross-Module Model Writes”Services should not directly modify models from other modules. Instead:
- Use the target module’s service methods (or a provider seam)
- Emit events that the target module handles
Future Improvements
Section titled “Future Improvements”The following improvements are candidates for future work:
- Finish the DI cutover (Stage C): delete the
GetRuntimeServicedual-write bridge once every consumer reads the injected handle - Stack State Machine: Centralize stack state transitions in the app module
- Integration Events: Use an event system for loose coupling between modules
- Extended Stack API: Include node connection info in Stack API responses (partially done)
Adding New Cross-Module Dependencies
Section titled “Adding New Cross-Module Dependencies”When adding a new cross-module dependency:
- Prefer the injected handle - Read it off
registry.Deps.Servicesthrough a consumer-defined accessor interface (or call an existing API endpoint). - Prefer a provider seam - For service-to-service needs, declare the seam as an interface your module owns and have the daemon wire an adapter.
- Inject per-call resolvers, not stored handles - tenant/scope and similar
request-scoped values come in as
func(ctx) ...resolvers. - Document the reason - Add a comment explaining the seam.
- Update this document - Add the dependency to the table above.
See Also
Section titled “See Also”- Tech Stack - Module pattern documentation
- API Design - API endpoint conventions
- Testing Standards - Test organization
daemon/composition.go- the composition root (daemon.Services)docs/proposals/di-migration-plan.md- the DI migration plan