ADR-003 — Module Layout¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.2 |
| Date | 2026-08-11 |
| Owner | Ruslan Gabitov |
| Supersedes | — |
| Refines | SAD-001 §9 Module Layout |
1. Context¶
SAD-001 §9 established the multi-module monorepo direction at the conceptual level (core library + future runtime/ + future adapters/*, each its own Go module, all in one git repository). ADR-002 §4.2 listed the eleven extension interfaces in scope but explicitly deferred "exact subpackage layout" to this ADR. This ADR closes those questions:
- Which subdirectories are Go modules (have their own
go.mod) vs ordinary subpackages? - Where exactly do the ADR-002 extension interfaces live within
pkg/? - What are the import-direction rules between core, runtime, adapters, and the existing
internal/glue? - What's the tag convention for per-module semver?
- What's the migration path from the current single-module state?
Current code state (relevant to module layout):
- One module today:
github.com/dr-dobermann/gobpmat the repo root (go.mod). pkg/contains:errs/,model/,set/,thresher/. Onlypkg/model/data/exposes any extension-relevant types today (FormalExpression,Source,PropertyAdder).internal/contains:eventproc/(EventHub interface lives here),exec/,instance/(Instance + track; token is a projection — per ADR-001),interactor/(TaskDistributor-equivalent),renv/(RuntimeEnvironment lives here),runner/,scope/.examples/already follows the multi-module pattern — three subdirectories, each with its owngo.mod.- No
runtime/, noadapters/yet.
Per ADR-002 §5, the engine-level extension accessors are factored into a public EngineRuntime interface promoted to pkg/. Three things an earlier draft planned to promote stay internal instead: EventHub (execution plumbing, not an extension point — ADR-002 §4.2); RuntimeEnvironment (it embeds internal types and now embeds the public EngineRuntime); and the human-interaction cluster Registrator/Interactor (its promotion + the TaskDistributor rename are deferred to a dedicated human-interaction ADR — ADR-001 §9). Eight new extension interfaces are introduced (Repository, Logger, Tracer, MetricsRecorder, Clock, MessageBroker, AuthorizationProvider, WorkerDispatcher) plus ExpressionEngine (which wraps existing FormalExpression). This ADR places them.
2. Decision¶
Multi-module monorepo with one Go module per concern that has independent dependency footprint and release cadence. Inside the core module, pkg/ exposes one subpackage per cohesive extension concern; internal/ holds the implementation glue. Modules are added incrementally (runtime/ first, then specific adapters) but their target directory positions and import-direction rules are locked now so adding a module is a directory creation, never a reorganization.
Summary table:
| Concern | Decision |
|---|---|
| Core library module | github.com/dr-dobermann/gobpm at repo root — current state preserved |
| Public extension catalogue | One pkg/ subpackage per cohesive concern (11 subpackages — see §4.2) |
| Default implementations | Bundled in their interface's package when small (no-op, simple in-memory); separated into a sibling subpackage when complex (e.g., pkg/repository/memrepo/) |
| Implementation glue | Stays in internal/; existing eventproc/, interactor/, scope/, instance/, renv/, exec/, runner/ retain their roles |
| Runtime layer | runtime/ submodule with its own go.mod. Scaffolded NOW (empty placeholder) per SAD-001 §9.2 to lock the boundary |
| Adapter modules | adapters/<name>/ per adapter, each its own go.mod. adapters/ directory created with at least one placeholder per ADR-002 §4.6 |
| Import direction | core → stdlib + google/uuid only; runtime → core + chosen adapters; adapters/* → core only; no cross-adapter imports |
pkg/ vs internal/ rule |
pkg/ public APIs MUST NOT expose internal/ types. pkg/ MAY import internal/ for implementation. internal/ MAY import pkg/ (e.g., internal/instance/Instance implements pkg/renv.RuntimeEnvironment) |
| Module versioning | Per-module semver. Core: vX.Y.Z. Submodules: <path>/vX.Y.Z per Go convention (e.g., runtime/v0.2.0, adapters/postgres/v0.1.0) |
| CI enforcement | golangci-lint depguard (or equivalent) enforces import-direction rules from day 1 — adding to existing check.yml per ADR-002 §8 recommendations |
3. Alternatives Considered¶
3.1 Module granularity¶
| Option | Description | Verdict |
|---|---|---|
| Single module for everything | One go.mod at root; all code (including future runtime + adapters) lives under it |
Rejected. Dependency pollution — library users importing core would transitively pull runtime/adapter deps. Defeats SAD-001 G2 (minimal core deps). |
| Multi-repo split (core + runtime + each adapter) | Each is a separate git repo | Rejected per SAD-001 §14. Solo-dev cognitive overhead; coordinated changes become multi-repo PRs. Re-evaluated at scale only. |
| Multi-module monorepo — chosen | One git repo, multiple go.mod files in subdirectories |
Selected per SAD-001. Solo-dev friendly; per-module dep isolation; per-module versioning; easy split-out later. |
3.2 pkg/ subpackage granularity¶
Eleven extension interfaces from ADR-002 need places. Three packaging granularities considered:
| Option | Description | Verdict |
|---|---|---|
Single pkg/extension/ for all interfaces |
One subpackage; users import one place | Rejected. Becomes a god-package over time; mixed responsibilities; harder to evolve individual concerns; doesn't match Go-stdlib convention (e.g., io.Reader lives in io, not in a unified interfaces package). |
| One subpackage per individual interface | pkg/repository/, pkg/logger/, pkg/tracer/, pkg/metricsrecorder/, pkg/clock/, etc. (~13 small packages) |
Rejected. Too granular — Logger, Tracer, and MetricsRecorder share clear cohesion (all observability sinks); separating them into different packages forces awkward cross-package imports for users wiring observability. |
| One subpackage per cohesive concern — chosen | Group cohesive interfaces; separate dissimilar ones | Selected. See §4.2 for the concrete catalogue. |
3.3 Default implementation locality¶
| Option | Description | Verdict |
|---|---|---|
| Defaults always in a sibling subpackage — chosen | Every interface package contains only the interface (plus tightly-coupled types: option types, error sentinels, value types the interface returns). The default implementation lives in a sibling subpackage. Examples: pkg/repository/ (interface only) + pkg/repository/memrepo/ (in-memory default); pkg/clock/ (interface only) + pkg/clock/syscl/ (system-clock default). |
Selected. Rationale: bundling the default in the interface package forces every importer to compile the default's code — including users who swapped to a non-default implementation. With subpackaging, adapter authors importing only pkg/repository/ get just the interface; users who want the default explicitly import pkg/repository/memrepo/. Clean separation of concerns AND honest "pay for what you use" semantics. |
| Defaults bundled in interface package | One package holds both the interface and the default impl | Rejected per the above. Bundles force imports of code users may not need; obscure the interface contract behind the default's implementation noise. |
Defaults in a sibling module (gobpm-defaults) |
Per ADR-002 §3.4 reconsideration | Rejected per ADR-002 — adds module overhead for no real win. |
3.4 Existing internal/ package retention vs reorganization¶
| Option | Description | Verdict |
|---|---|---|
Retain existing internal/ structure — chosen |
internal/instance/, internal/scope/, internal/eventproc/, etc. keep their current roles; only the promoted INTERFACES move out to pkg/ |
Selected. The existing structure is well-thought-out (per ADR-001 + ADR-002 acknowledgments). Reorganizing for the sake of consistency would create noise without value. |
Reorganize internal/ to mirror pkg/ |
Internal packages renamed to match public ones | Rejected. The internal package names reflect implementation concerns (event-procession machinery, runner-orchestration, scope-tree) that don't perfectly map to user-facing extension concerns. Forced mirroring is over-engineering. |
3.5 Module versioning + tag convention¶
| Option | Description | Verdict |
|---|---|---|
| Per-module semver with path-prefixed tags — chosen | Core: vX.Y.Z. Submodules: <path>/vX.Y.Z (e.g., runtime/v0.2.0, adapters/postgres/v0.1.0) |
Selected. Standard Go multi-module convention; tooling (Go module resolver) handles it natively. |
| Synchronized versioning across all modules | All modules share a single version number; bump together | Rejected. Forces unrelated releases; loses independent-evolution benefit of multi-module. |
| No tags, branch-only versioning | Use git branches as version markers | Rejected. Defeats go get @version semantic; go.mod works best with tags. |
3.6 Scaffolding cadence¶
| Option | Description | Verdict |
|---|---|---|
| Scaffold all target modules upfront with placeholders — chosen | Create empty runtime/ and adapters/ modules now, even if they have no real code |
Selected per SAD-001 §9.2. Establishes import-direction discipline on day 1; first real code lands without restructuring; CI can enforce the boundary from the start. |
| Add modules only when first real code is ready | Wait until runtime/ has a real server before creating its go.mod |
Rejected. Risks the boundary not being respected once real code accumulates. The cost of a placeholder go.mod + doc.go is essentially zero. |
4. Decision Detail¶
4.1 Module layout (full target)¶
github.com/dr-dobermann/gobpm/ ← repo root
├── go.mod ← core module
├── cmd/ ← thin CLI entry points (current state)
├── pkg/ ← PUBLIC API
│ ├── thresher/ Engine façade + Option type
│ ├── model/ BPMN STANDARD types — everything from the spec lives under this tree
│ │ ├── activities/, events/, gateways/, flow/, (existing BPMN element types)
│ │ │ data/, foundation/, … FormalExpression, Source, PropertyAdder stay in pkg/model/data/
│ │ └── expression/ NEW — ExpressionEngine extension point (evaluates BPMN FormalExpression)
│ │ └── goexpr/ Go-native default impl
│ ├── errs/ Errors (existing)
│ ├── set/ Utilities (existing)
│ ├── renv/ RuntimeEnvironment interface (interface-only; impl is internal/instance/Instance)
│ ├── repository/ Repository interface (interface-only)
│ │ └── memrepo/ In-memory default impl
│ ├── observability/ Logger, Tracer, MetricsRecorder interfaces (interface-only)
│ │ ├── slog/ slog.Default()-based Logger default
│ │ └── noop/ No-op Tracer + MetricsRecorder defaults
│ ├── clock/ Clock interface (interface-only)
│ │ └── syscl/ System-clock default
│ ├── messaging/ MessageBroker interface (interface-only; EventHub stays internal)
│ │ └── membroker/ In-memory MessageBroker default
│ ├── auth/ AuthorizationProvider interface (interface-only)
│ │ └── allowall/ Allow-all default
│ ├── tasks/ TaskDistributor + WorkerDispatcher interfaces (interface-only)
│ │ ├── localdistributor/ In-process TaskDistributor default
│ │ └── localdispatcher/ In-process WorkerDispatcher default
│ └── extension/ Optional capabilities (Starter, Stopper, HealthChecker)
├── internal/ ← PRIVATE IMPLEMENTATION (post-migration target)
│ ├── instance/ Instance, track, stepInfo; Token projection (per ADR-001)
│ ├── scope/ Scope tree implementation
│ ├── runner/ Process runner
│ └── exec/ Execution machinery
│ #
│ # NOTE: internal/eventproc/, internal/interactor/, internal/renv/ are
│ # removed after migration (see §4.6 step 12). Their interfaces moved to
│ # pkg/ and their default implementations moved to pkg/<concern>/<default>/
│ # subpackages. If any truly-internal helpers remain after the migration
│ # audit, they relocate to the nearest existing internal/ package or to a
│ # new internal/ package with a precise name reflecting just what stays
│ # internal.
├── examples/ ← multi-module, existing
│ ├── basic-process/ Each its own go.mod
│ ├── simple-timer/
│ └── timer-event/
├── runtime/ ← SCAFFOLD NOW per SAD-001 §9.2
│ ├── go.mod Submodule
│ ├── doc.go Placeholder package doc
│ ├── cmd/gobpm-server/main.go Stub: prints "not yet implemented"
│ └── (server, tenancy, auth, obs, diag added later per ADR-004)
├── adapters/ ← SCAFFOLD NOW per SAD-001 §9.2
│ ├── memrepo-tests/ Placeholder — conformance suite for Repository in-memory default
│ │ └── go.mod
│ └── (postgres, otel, etc. added as needed)
└── docs/ ← shared documentation
4.2 pkg/ extension subpackage catalogue¶
Each subpackage holds the interface plus its default implementation(s). Subpackages chosen for cohesion — related interfaces grouped, unrelated ones separated.
All interface packages are interface-only. Defaults always live in sibling subpackages (per §3.3). Adapter authors and users who swap defaults pay nothing for the default impl's compiled code.
| Subpackage | Interfaces | Default impl location | Cohesion rationale |
|---|---|---|---|
pkg/thresher/ |
Thresher (the engine façade); Option type; WithRepository(...), WithLogger(...), etc. |
n/a — the engine itself is the entry point | The public engine entry point. |
pkg/renv/ |
RuntimeEnvironment, EngineRuntime; and the optional side-capability traits of ADR-002 §8.3 — Migrator, ClusterAware, Starter, Stopper, HealthChecker, RuntimeAware |
n/a — RuntimeEnvironment is implemented by internal/instance/Instance; the traits are implemented by whichever adapter needs them |
One interface, one package; central enough that nesting it elsewhere would obscure it. There is no pkg/extension/ package: the traits are about what a seam can do for the runtime, Migrator and ClusterAware already lived here, and splitting six related optional interfaces across two packages buys nothing — an adapter implements them structurally, importing nothing. |
pkg/model/ |
BPMN STANDARD types (existing — Activity, Event, Gateway, FormalExpression, …) | n/a — these are model types, not extension points | Everything from the BPMN standard lives in this tree. Extension points that evaluate BPMN concepts (e.g., ExpressionEngine) live as pkg/model/<concern>/ subpackages, not at the pkg/ root. |
pkg/model/expression/ |
expression.Engine (extension point for evaluating BPMN FormalExpression) |
pkg/model/expression/goexpr/ (Go-native functors) and lite/ (a small text expression language); both auto-wired, routed by language claim |
Evaluates a BPMN spec concept (FormalExpression) — kept under the model tree per BPMN-standard-locality preference. |
pkg/repository/ |
Repository |
pkg/repository/memrepo/ (in-memory, non-durable) |
The persistence concern stands alone. |
pkg/observability/ |
Logger, Tracer, MetricsRecorder |
no slog subpackage: Logger is defined as the leveled subset of *slog.Logger, so slog.Default() satisfies it directly and the engine wires it. pkg/observability/noop/ (no-op Tracer + MetricsRecorder), plus memtrace/ and memmetrics/ for tests and local inspection |
All three are observability sinks consumed together (a span typically logs + records metrics + adds attributes); separating their interfaces forces awkward multi-import wiring. A gobpm slog package would wrap a type that already satisfies the interface. |
pkg/clock/ |
Clock |
pkg/clock/syscl/ (system clock — time.Now wrapper) |
Distinct from observability — used by Timer events, not by sinks; deserves its own package for testability injection. |
pkg/messaging/ |
MessageBroker |
pkg/messaging/membroker/ (in-memory MessageBroker) |
External message ingress / correlation. EventHub is not here — it stays internal (execution plumbing, ADR-002 §4.2). |
pkg/auth/ |
AuthorizationProvider |
pkg/auth/allowall/ (allow-all default) |
Standalone concern; identity-providers and tenancy belong in runtime/, not core. |
pkg/tasks/ |
WorkerDispatcher |
pkg/tasks/localdispatcher/ (in-process default) |
Remote-execution task dispatch. Human interaction is not here — it has its own package, below. |
pkg/interactor/ |
TaskDistributor (human-task announcement/withdrawal) |
pkg/interactor/console/; the engine's zero-config default is the no-op NopDistributor |
A promoted seam. §4.6 step 5 deferred human interaction to internal/, and the code has outrun that: under a closed port list, a package in pkg/ is a public extension point, so the position is already taken. What stays open is the human-interaction DESIGN that ADR-001 §9 reserves — the naming and the contract — not whether the seam is public. |
pkg/rules/ |
rules.Engine (Business Rule Task evaluation) |
pkg/rules/gorules/ (Go-functor default) |
Decision evaluation. A DMN decision-table engine is adapters/dtable rather than a battery — see §4.2.1. |
pkg/script/ |
script.Engine (Script Task evaluation) |
pkg/script/gofunc/ (named Go functions, zero dependency, opt-in); adapters/lua for interpreted source |
The one port whose interpreted-source implementation cannot be a battery: it needs an interpreter, and the core holds to stdlib + uuid (SAD-001 G2). The zero-config default is the empty ##None registry, and RegisterProcess refuses a model whose script formats no configured engine claims. |
pkg/datastore/ |
datastore.DataStore (BPMN Data Store, §10.4.1) |
pkg/datastore/memstore/ (in-memory) |
Engine-global data outliving any instance. |
Conformance test helpers¶
Each interface package SHOULD ship its conformance helper as an exported function in a <pkg>test sibling subpackage (Go standard idiom, see database/sql/driver/driverstest, net/http/httptest):
| Test helper location | Helps test |
|---|---|
pkg/repository/repositorytest/ |
Any Repository implementation |
pkg/messaging/messagingtest/ |
Any MessageBroker implementation |
pkg/clock/clocktest/ |
Any Clock implementation (incl. fake clocks for time-dependent tests) |
pkg/model/expression/expressiontest/ |
Any ExpressionEngine implementation |
pkg/tasks/taskstest/ |
Any WorkerDispatcher implementation. Not TaskDistributor — that interface lives in pkg/interactor/, so its suite, if one is wanted, belongs there |
pkg/auth/authtest/ |
Any AuthorizationProvider implementation |
Logger, Tracer, MetricsRecorder don't need conformance suites — their interfaces are too simple (single-method sinks) to warrant one. Adapters self-test directly.
4.2.1 Battery or adapter¶
The tree holds two kinds of implementation, and until this was written down
nothing said which was which — adapters/dtable (a dependency-free
rules.Engine) sat beside pkg/rules/gorules (also dependency-free) with no
stated reason for the difference.
| Battery | Adapter | |
|---|---|---|
| Test | every user wants it available | you opt into it |
| Lives in | pkg/<port>/<name>/ — a subpackage of its port |
adapters/<name>/ — its own Go module |
| Dependencies | stdlib + uuid only (SAD-001 G2) |
may take third-party ones |
| Wiring | available with no module dependency; auto-wired when it is complete on its own | wired explicitly by the user |
| Examples | memrepo, allowall, syscl, membroker, gorules, memstore; gooper and gofunc for the host-content case |
postgres (pgx), lua (gopher-lua), dtable (optional DMN capability) |
Two clarifications, both of which the tree already demonstrates:
It is not "needs a dependency → its own module". dtable needs none and is
still an adapter, because a DMN decision-table engine is not something every
build should carry.
And auto-wiring is a consequence of the battery test, not part of it. A
battery is auto-wired when it is useful the moment it exists — memrepo is a
working repository as constructed. A battery whose content comes from the host
is empty until the host fills it, so auto-wiring one would install a default
that can do nothing: gooper (Service Tasks) and gofunc (Script Tasks) are
both constructed by the user for that reason, and both are batteries —
pkg/-located, dependency-free, reachable without adding a module.
Batteries are not moved to adapters/. They are separate Go modules there,
so core could not wire one without taking a module dependency — which ends
batteries-included at the module boundary. The pay-for-what-you-use property
§3.3 wanted is already delivered by the subpackage split: a user wiring
postgres compiles no memrepo. The standard library arranges itself the same
way (database/sql + database/sql/driver).
4.3 What moves to pkg/ vs what stays in internal/¶
Rule of thumb: an interface goes public iff external adapters need to implement it. Once an interface is in pkg/, its default implementation belongs in the same pkg/ subpackage (in-package or in a sibling subpkg if substantial) — NOT split across the public/private boundary. The pkg/ subpackage is then self-contained from a user's import perspective.
What stays in internal/ is purely-internal supporting machinery the public interface and its default impl rely on but which external adapters never need to touch.
What promotes to pkg/ (interface + default impl together)¶
| Current location | Promoted to | What goes with it |
|---|---|---|
internal/eventproc/EventHub (+ eventhub/ impl) |
stays internal | Execution plumbing, not an extension point (ADR-002 §4.2). No move. |
internal/renv engine-level accessors → EngineRuntime |
pkg/renv/ (the public EngineRuntime interface only) |
Only the engine-level accessors go public as EngineRuntime (implemented by Thresher). RuntimeEnvironment stays in internal/renv — it embeds internal scope.Scope/EventProducer/RenderRegistrator plus the public EngineRuntime; Instance implements it. |
internal/interactor/Registrator cluster |
stays internal (deferred) | Promotion + the Registrator → TaskDistributor rename are owned by a dedicated human-interaction ADR (ADR-001 §9). No move. |
pkg/model/data/FormalExpression interface (already in pkg/) |
pkg/model/expression/ExpressionEngine (new interface that wraps FormalExpression evaluation) |
FormalExpression stays in pkg/model/data/ — it IS a BPMN model element. ExpressionEngine is a new extension point that evaluates FormalExpressions; it lives under pkg/model/expression/ because it directly evaluates a BPMN spec concept, and everything BPMN-adjacent stays in the pkg/model/ tree. Default impl in pkg/model/expression/goexpr/. |
What stays in internal/¶
internal/ package |
Role | Why internal |
|---|---|---|
internal/instance/ |
Instance, track, stepInfo types + the Token projection per ADR-001 |
The execution machinery is implementation; users interact with it via the pkg/thresher/ façade and the pkg/renv.RuntimeEnvironment interface that Instance implements. track stays here — no package split. A split behind a host interface (to compiler-enforce ADR-001's event-only invariant) was considered and decided against (2026-07-02): track is internal, non-extension-point code sharing the event-loop hot path with Instance, so a package boundary + host-interface indirection is ceremony that harms the effectiveness and observability of Instance execution without a real extensibility need. (This is separate from the Instance size-decomposition — splitting instance.go into more files in the same package — deferred in ADR-012 §2.5.) |
internal/scope/ |
Scope tree implementation backing pkg/renv.RuntimeEnvironment.Scope() |
Implementation detail of how data scoping works; the Scope interface is exposed via the RuntimeEnvironment embedding. |
internal/runner/, internal/exec/ |
Execution machinery (the orchestration loop, node-execution dispatch) | Implementation detail; no extension points here. |
internal/eventproc/ |
The full event-distribution mechanism — EventHub, EventProducer, EventProcessor, EventWaiter, and the eventhub/ impl |
Execution plumbing; stays internal in full (ADR-002 §4.2). |
internal/interactor/ |
The human-interaction cluster (Registrator/Interactor/RenderController) + impl |
Stays internal; promotion deferred to the human-interaction ADR (ADR-001 §9). |
internal/renv/ |
RuntimeEnvironment (embeds the public EngineRuntime) + composition glue |
Stays internal; only EngineRuntime is promoted to pkg/. |
The boundary discipline: pkg/ contains complete, self-contained extension contracts (interface + working default impl). External adapters and users import pkg/ and get everything they need. internal/ contains the engine machinery that consumes those pkg/ interfaces but doesn't publish anything externally.
4.4 Import-direction rules¶
The rules are enforced by golangci-lint depguard (or equivalent) in CI from day 1.
| From → To | Allowed? | Notes |
|---|---|---|
cmd/* → pkg/*, internal/* |
YES | Top-level entry points compose everything. |
pkg/* → pkg/* |
YES | Public packages may import each other freely within the module. |
pkg/* → internal/* |
YES, with caveat | Permitted at the implementation level; the caveat: pkg/* public APIs MUST NOT EXPOSE internal/* types in function signatures, struct fields, return types, or interface methods. Implementation may use internal types; the contract surface MUST be public-types-only. |
internal/* → pkg/* |
YES | Common — e.g., internal/instance/Instance implements pkg/renv.RuntimeEnvironment so it imports the interface from pkg/renv/. |
internal/* → internal/* |
YES | Implementation packages cooperate freely. |
examples/* → pkg/* |
YES | Each example module imports core. |
examples/* → internal/* |
NO | Examples demonstrate the public surface; reaching into internal would mislead users. Enforced at the Go module level (Go's internal/ rule already blocks external imports). |
examples/* → runtime/* |
NO | Examples demonstrate the embedded library use case; server wiring is its own example category later. |
examples/* → adapters/* |
YES | Amended. The original rule forbade this, and the code had already gone the other way: decision-table demonstrates dtable and script-task demonstrates lua. An adapter nobody demonstrates is an adapter nobody can learn to wire, and adapters exist to be wired by users — showing that is the example's job. Only the server boundary stays closed. |
runtime/* → pkg/* |
YES | Runtime composes the engine. |
runtime/* → internal/* |
NO | Runtime is a SEPARATE Go module; Go's internal/ rule blocks the import at language level. This is the architectural enforcement. |
runtime/* → adapters/* |
YES, by user choice | Runtime imports the adapter modules the user wires in. |
adapters/* → pkg/* |
YES | Adapter implements public interfaces. |
adapters/* → internal/* |
NO | Adapter is a SEPARATE Go module; blocked by Go's internal/ rule. |
adapters/* → other adapters/* |
NO | Hard rule (per ADR-002 §3.5 / §4.6). User composes shared resources at construction time. |
pkg/* → runtime/* or adapters/* |
NO | The core MUST NOT depend on its consumers. Strictly enforced. |
CI enforcement¶
Adding to .github/workflows/check.yml:
- name: Enforce import-direction rules
run: |
# golangci-lint depguard configured in .golangci.yml
golangci-lint run --disable-all --enable depguard ./...
.golangci.yml depguard config (illustrative; final form per implementation):
linters-settings:
depguard:
rules:
core-no-runtime:
list-mode: lax
files: ["pkg/**/*.go", "internal/**/*.go"]
deny:
- pkg: "github.com/dr-dobermann/gobpm/runtime"
desc: "core MUST NOT import runtime"
- pkg: "github.com/dr-dobermann/gobpm/adapters"
desc: "core MUST NOT import adapters"
examples-no-internal:
files: ["examples/**/*.go"]
deny:
- pkg: "github.com/dr-dobermann/gobpm/internal"
desc: "examples demonstrate public API only"
(Go's own internal/ rule already blocks runtime/ and adapters/* from importing core's internal/ — the depguard rules above cover the cases Go's mechanism doesn't.)
4.5 Module versioning and release¶
Per-module semver. Tags use the standard Go multi-module convention:
| Module | Tag format | Go module path used by consumers |
|---|---|---|
Core (github.com/dr-dobermann/gobpm) |
vX.Y.Z |
go get github.com/dr-dobermann/gobpm@v0.5.0 |
runtime/ submodule |
runtime/vX.Y.Z |
go get github.com/dr-dobermann/gobpm/runtime@v0.2.0 |
adapters/postgres/ |
adapters/postgres/vX.Y.Z |
go get github.com/dr-dobermann/gobpm/adapters/postgres@v0.1.0 |
adapters/otel/ |
adapters/otel/vX.Y.Z |
(same pattern) |
Pre-1.0 (current): minor bumps may include breaking changes (Go semver convention). Post-1.0: discipline per ADR-002 §8.6 — add-only minor changes, deprecation paths, major version for breakage.
Pre-release tags: vX.Y.Z-rc.N, vX.Y.Z-beta.N per semver.
Adapter / runtime compatibility declaration¶
Each adapter / runtime module declares its minimum compatible core version in go.mod:
// runtime/go.mod
module github.com/dr-dobermann/gobpm/runtime
go 1.25
require (
github.com/dr-dobermann/gobpm v0.5.0
)
Major version mismatch is a compile-time failure (Go's resolver enforces).
4.6 Migration from current state¶
Incremental, no big-bang reorg. Each step is a small focused change.
- Scaffold
runtime/submodule. Createruntime/go.mod,runtime/doc.go,runtime/cmd/gobpm-server/main.go(stub). Adds the boundary; no code yet. - Scaffold
adapters/directory. Create at least one placeholder (e.g.,adapters/memrepo-tests/with the conformance helper that the in-memory Repository default passes — establishes the adapter testing pattern). EventHubstays internal — no move (execution plumbing, not an extension point; ADR-002 §4.2).internal/eventproc/keeps the full mechanism.- Factor
EngineRuntime(the engine-level extension accessors) intopkg/renv/(public), implemented byThresher. (Landed, and further than planned:RuntimeEnvironmentmoved topkg/renv/as well, rather than staying ininternal/renv/. It is the interface a node execution receives, so an out-of-tree implementation of any port has to name its type; keeping it internal would have made the public accessors reachable only through an unnameable one.internal/instance/Instancestill implements it.) - ~~Human interaction is deferred — the
internal/interactor/cluster stays internal.~~ (Superseded.pkg/interactor/is public, withTaskDistributorand aconsolebattery — see §4.2. Under a closed port list, a package inpkg/IS a public extension point, so the seam's position was taken by the code. What ADR-001 §9 still reserves is the human-interaction DESIGN — the contract and the naming — not whether the seam is public.) - Create new
pkg/subpackages for the seven net-new interfaces (Repository, Logger, Tracer, MetricsRecorder, Clock, MessageBroker, AuthorizationProvider, WorkerDispatcher, ExpressionEngine) with their default implementations. - Add functional options in
pkg/thresher/(WithRepository,WithLogger, etc., per ADR-002 §4.4). - Refactor
Thresher.Newto accept options and wire defaults internally. - ~~Add
pkg/extension/withStarter,Stopper,HealthChecker.~~ (Superseded. The traits live inpkg/renv/capabilities.gobesideMigratorandClusterAware, which were already there — see §4.2. They are implemented structurally, so an adapter imports nothing to satisfy them, and a separate package would have split six related optional interfaces across two locations for no gain.) - Add CI rule for import-direction enforcement (golangci-lint depguard) to
.github/workflows/check.yml. - Add conformance test helper packages (
pkg/repository/repositorytest/, etc.) for the extension types where they apply. - Remove only genuinely-empty
internal/directories. Note thatinternal/eventproc/,internal/interactor/, andinternal/renv/remain (EventHub internal; human-interaction deferred;RuntimeEnvironmentinternal). Delete a directory only if it genuinely ends up empty; do NOT leave empty markers. Remove any other obsolete docs that surface during the migration audit.
Each step is its own SRD-class change (per project SDD discipline) after this ADR is Accepted.
5. Conception vs Current Code — Deliberate Departures¶
Each row records what the code did with the departure. Closed at
59f8461, under #269.
| Topic | Original state | This ADR | Status |
|---|---|---|---|
| Number of modules | One (go.mod at root) |
Three categories: core (1), runtime (1), adapters (N) — scaffolded incrementally | CLOSED. Six go.mod files: core, runtime/, and adapters/{sqlite,dtable,lua,postgres}/. Every one of them is implemented — the last placeholder, adapters/sqlite, was built out in #316, so the catalogue no longer advertises a module a user cannot reach for. |
| Extension interface location | internal/eventproc/, internal/renv/, internal/interactor/, pkg/model/data/ (scattered; mostly internal) |
The cohesive pkg/* subpackages of §4.2; EventHub stays internal |
CLOSED, with one departure from the departure. Every port named in §4.2 is public, and EventHub is still internal/eventproc/ as intended. Human interaction did NOT stay internal: pkg/interactor/ exists, and §4.2 now records it as a promoted seam rather than pretending otherwise. |
| Default implementation location | Existing defaults are in internal/* packages |
Always in a sibling subpackage of the interface (§3.3), so users who swap a default pay nothing for it | CLOSED. Every port's battery is a sibling subpackage — memrepo, allowall, syscl, membroker, localdispatcher, gorules, memstore, gofunc, console, noop/memtrace/memmetrics. find internal -type d -empty returns nothing (§4.6 step 12). The one deliberate exception is Logger, whose default is slog.Default() — see §4.2. |
| Thresher constructor | Thresher.New(id string) — no options |
Thresher.New(id, opts ...Option) (per ADR-002 §4.4) |
CLOSED. 24 With* options in pkg/thresher/options.go. |
| Conformance test helpers | Not present | One <pkg>test/ sibling subpackage per applicable interface |
CLOSED. All six exist: repositorytest, clocktest, messagingtest, expressiontest, taskstest, authtest. Each publishes Conformance(t, factory) and carries a negative control proving it can fail — a helper that cannot fail proves nothing about what it accepts. |
| Import-direction enforcement | None in CI | golangci-lint depguard rules in .golangci.yml, enforced by check.yml |
CLOSED. Six depguard rule groups, each citing this §4.4. Note the rules only became load-bearing for examples once the lint gate actually reached them — an exclusions.paths entry had been suppressing every finding in all 49 example modules, so examples-no-internal had never fired — measured by removing the entry and re-running (#269). |
examples/* content |
Demonstrate pkg/thresher/ + pkg/model/ |
Eventually demonstrate the extension wiring | PARTLY CLOSED, and deliberately open-ended. 49 example modules exist, each built AND run under CI; adapter wiring is demonstrated (decision-table, script-task), which is why §4.4's examples/* → adapters/* ban is amended. "Each WithXxx shown in a small example" is not a finishable requirement — options keep arriving — so it is a standing practice, not a gate. |
| Tag conventions | Single tag v0.0.1 for the whole repo |
Per-module tags using path-prefixed semver (vX.Y.Z for core; <path>/vX.Y.Z for submodules) |
CLOSED. make tag cuts the core tag from .version, additionally pinning the vendored BPMN-spec tree hash (SAD-001 §14); the submodule pattern is documented above the target. No submodule has been tagged yet — none has shipped. |
Every row is closed or explicitly standing, which is what lets this ADR move
to Accepted. The migration landed incrementally rather than as the named
project of §4.6, so the value of this table is no longer the plan — it is the
record of which departures were real and what became of each.
6. Consequences¶
6.1 Pros¶
- Dependency isolation enforced architecturally. Go's module system +
internal/rule + CI-enforced depguard prevent the most common pollution paths. - Per-module evolution. Core can ship v1.0 while runtime is still v0.x — independent stability contracts.
- Solo-dev friendly. One git repo, one issue tracker, one CI config, single PR per cross-cutting change. The multi-module overhead is a few
go.modfiles, not multi-repo coordination. - Adapter ecosystem enabled. Clear pattern for what an adapter module looks like; clear contract for what it imports (core only); CI catches violations.
- Migration is incremental. Each step is small and well-bounded; no big-bang reorg.
- Conformance testing standardized. Every adapter passes the same suite as the in-memory default — same Go-test idiom (per ADR-002 §8.4).
6.2 Cons¶
- More subpackages to maintain. Eleven
pkg/*subpackages vs the current four. Mitigated by each being small and focused. pkg/*→internal/*rule is a convention, not a Go-language rule. Linter enforcement catches most violations but it's not airtight. Discipline still required.- Adapter authors face per-module overhead. Each adapter must maintain its own
go.mod, version, and conformance tests. Mitigated by the small-and-focused-per-adapter pattern. - Multi-module tagging is more cognitive load.
runtime/v0.2.0is less familiar thanv0.2.0to contributors coming from single-module projects. Documented inCONTRIBUTING.mdand Makefile help.
6.3 Implications for adjacent decisions¶
- ADR-001 Execution Model: Instance's
internal/instance/location preserved; its implementation ofpkg/renv.RuntimeEnvironmentis the bridge between internal types and public interface. - ADR-002 Extension Architecture: this ADR places ADR-002's catalogue concretely. ADR-002's "exact subpackage layout deferred" is now resolved.
- ADR-004 Runtime Environment Contract: will use the
runtime/submodule scaffolded here. Will define what goes insideruntime/(HTTP server, tenancy middleware, AuthN/Z integrations, observability stack, diagnostic endpoints, health-check endpoint). - SAD-001 §13 Distribution & Scale (preliminary):
adapters/*modules are where distribution adapters (gRPC worker dispatch, distributed message broker, etc.) live.
7. Verification¶
| What | How |
|---|---|
| Module boundaries enforced | CI step: go mod tidy + go build ./... succeeds across all modules. Manual: go mod graph from each module shows correct dependency tree (no cross-adapter, no core-to-runtime/adapter). |
| Import-direction rules enforced | CI step: golangci-lint run --enable depguard passes. Test: introduce a temporary forbidden import; assert CI fails. |
pkg/* doesn't expose internal/* types |
Static check via documentation tool (godoc) or manual review of pkg/* exported declarations. Audit can be scripted (parse exported types; ensure none come from internal/). |
| Each extension interface has a default impl in core | Per-interface test: var _ Logger = (*defaultLogger)(nil); assert pkg/observability provides a default constructor that returns a working Logger. Repeat per interface. |
| Conformance test helpers exist | Per interface: import _ "github.com/dr-dobermann/gobpm/pkg/repository/repositorytest" succeeds and exposes the helper function. |
runtime/ scaffold runs |
go run github.com/dr-dobermann/gobpm/runtime/cmd/gobpm-server prints the placeholder message; no panics. |
| Per-module versioning works | Test: tag runtime/v0.0.1 locally; go get github.com/dr-dobermann/gobpm/runtime@v0.0.1 resolves correctly (manual test using local replace directive). |
| Examples still compile | CI step: cd examples/basic-process && go build ./... (and similar for other examples). Each example imports only pkg/, never internal/ or runtime/. |
| Migration steps are independent | Each of the 11 migration steps in §4.6 lands as its own commit; CI passes after each. No "big bang" required. |
Acceptance gate (Draft → Accepted): the layout is implemented per §4.1; CI enforces §4.4 import rules; the migration steps in §4.6 are executed (or planned + tracked as SRDs).
8. Enterprise-Readiness Recommendations¶
8.1 Package documentation¶
Every pkg/ subpackage SHOULD have a thorough doc.go covering:
- Purpose — what the package solves in one sentence.
- Stability contract — what's stable, what's experimental.
- Wiring example — minimal code snippet showing how to use the interface with Thresher.
- Default behavior — what the package's defaults do (or don't do) so users know whether they need a real adapter.
- See also — pointer to relevant ADRs and bpmn-spec references.
godoc is the primary discovery surface for library users; investing in it pays back at every onboarding.
8.2 Adapter author template¶
A reference adapter SHOULD exist as a working template once adapters/ has its first real adapter. The template includes:
- go.mod with correct core version constraint.
- doc.go per §8.1.
- Tests using the conformance helper from <interface_pkg>test/.
- A README documenting deployment requirements (e.g., "requires PostgreSQL 13+ with extension pg_trgm").
- A CHANGELOG.md per the project's bilingual / changelog conventions.
Without a template, the first three adapters land with three different file layouts. Establishing the template early keeps the ecosystem coherent.
8.3 Module version-bump discipline¶
When making a change in core that affects a published interface (e.g., adding a method to Repository because a new lifecycle event needs to be persisted), the version-bump cascade is:
- Core gets minor bump (or major if breaking).
- Each downstream adapter implementing the changed interface gets a corresponding minor bump.
- runtime/ may need a bump if it wires the changed interface.
This cascade SHOULD be documented per-release in the changelog so adapter maintainers know what to retest.
8.4 Workspace mode for cross-module development¶
For local development across multiple modules (e.g., editing core and adapters/postgres simultaneously), use Go workspace mode (go.work). A go.work file at repo root:
go 1.25
use (
.
./runtime
./adapters/postgres
./examples/basic-process
./examples/simple-timer
./examples/timer-event
)
go.work is .gitignore'd (it's developer-machine state). The pattern is documented in CONTRIBUTING.md. Without workspace mode, multi-module edits require replace directives in go.mod files which are easy to forget to revert.
8.5 Private vs published modules¶
If the project ever has internal adapters (e.g., a Darlean-specific adapter that isn't open-sourced), they live in a SEPARATE repository, not under adapters/. The adapters/ directory is for adapters that ship with the project. Hosting closed-source modules under the open-source repo creates licensing and maintenance ambiguity.
The interface they implement is still pkg/*; the host project imports it via go get like any third-party module.
8.6 Long-term import stability¶
Once an interface is published in pkg/*, its import path is a stability contract. Renaming or moving a package after v1.0 is a major version bump per Go semver convention. The cost of moving pkg/messaging/ → pkg/msg/ post-1.0 is breaking every adapter and user.
Implication: choose package names carefully at v0.x. Once a name ships in a 1.0+ release, it's effectively permanent. Aliasing via import obs "github.com/dr-dobermann/gobpm/pkg/observability" lets users shorten import paths in their own code without forcing the canonical name to change.
9. References¶
- SAD-001 Vision & Architecture — §9 Module Layout (this ADR refines); §14 Repository & Release Strategy
- ADR-001 Execution Model — preserves
internal/instance/structure that this ADR locks in - ADR-002 Extension Architecture — defines the 11 extension interfaces this ADR places; §3.5 cross-adapter rule formalized in §4.4 of this ADR
- Go modules reference: Go Modules: Multi-module workspaces — the
go.workmechanism §8.4 references - Go modules reference: Tag versions for repositories with multiple modules — the path-prefixed tag convention §4.5 uses
- golangci-lint
depguard: docs — the import-direction enforcement mechanism
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-08-10 | Ruslan Gabitov | Accepted. Iterated as a Draft since 2026-05-30, with amendments folded in rather than versioned — an intermediate version nobody accepted is noise a later reader has to reconcile, and a stale pin in every document that referenced it. The final round (#269) reconciled §4.2 with the code the migration actually produced: no pkg/extension/ (the ADR-002 §8.3 traits live in pkg/renv beside Migrator and ClusterAware), no pkg/observability/slog/ (slog.Default() satisfies Logger directly), pkg/interactor/ recorded as a promoted seam rather than deferred, and rules, script and datastore added — they existed and the catalogue had never named them. It also added §4.2.1's battery-vs-adapter criterion, amended §4.4 to permit examples/* → adapters/*, and closed §5's departure table row by row. From here the document is Accepted, so the next change to it is a v.2 — which starts as Draft until its own changes are implemented. |
| v.2 | 2026-08-11 | Ruslan Gabitov | The adapter catalogue no longer advertises a module a user cannot reach for. §5's module-count row recorded adapters/sqlite as a doc.go placeholder tracked by #316; the adapter is now implemented, so the row states what is true. Six modules, six implementations. Also drops the version pins from this document's outgoing SAD/ADR references: a pin exists so a historical reader can tell which redaction was meant, and that premise does not hold in a process where bumping a document updates everything related to it in the same change-set — the pin then only supplies something for a later bump to falsify. Direction is unaffected and still binding: up or sideways, never down. |