Layout¶
BC structure, imports, naming, bootstrap, shared code.
Two axes on purpose: aggregates own the data shape so the domain stays explicit, slices own the use cases so a feature lives in one folder. Modular Monolith on the macro side, Vertical Slice on the micro. Keeping both stops the codebase from collapsing into either pure DDD or pure feature-folders.
BC layout¶
Two-axis: aggregates own data shape; features (vertical slices) own use cases.
cora/<bc>/
├── __init__.py # re-exports public BC surface
├── _bootstrap.py # BC-internal constants
├── _projections.py # register_<bc>_projections(registry) entry point
├── _<aggregate>_update_handler.py # update-handler factory hoist (when n>=3 update slices share scaffolding)
├── errors.py # BC-application-layer errors
├── routes.py # register_<bc>_routes(app)
├── tools.py # register_<bc>_tools(mcp, *, get_handlers)
├── wire.py # <Bc>Handlers bundle + wire_<bc>(deps)
├── aggregates/
│ └── <aggregate>/
│ ├── state.py # state + value objects + domain errors
│ ├── events.py # event classes + union + payload helpers
│ ├── evolver.py # evolve(state, event) + fold(events)
│ ├── read.py # load_<aggregate> (fold-on-read)
│ └── <vo_module>.py # aggregate-internal VOs (e.g. settings_validation, hazard_classification)
├── projections/
│ └── <name>.py # read-side projection (consumed by list_* queries)
└── features/
├── <verb>_<aggregate>/ # one folder per COMMAND
│ ├── command.py
│ ├── decider.py
│ ├── handler.py
│ ├── route.py
│ ├── tool.py
│ └── context.py # OPTIONAL: cross-aggregate pre-load before pure decider
├── append_<entry>/ # entry-append variant (no decider; handler writes via per-category port)
│ ├── command.py
│ ├── handler.py
│ ├── route.py
│ └── tool.py
└── get_<aggregate>/ # one folder per QUERY (no decider)
├── query.py
├── handler.py
├── route.py
└── tool.py
Each slice's __init__.py re-exports its public surface so callers write register_actor.bind(deps). Events live in the aggregate folder, not the slice: they're intrinsic facts about the aggregate's history.
Pairs Modular Monolith (BCs as macro-modules) with Vertical Slice (slices as micro-units). Aggregates stay explicit so the domain doesn't fragment into use cases.
Three slice shapes¶
The slice-contract fitness function (apps/api/tests/architecture/test_slice_contract.py) recognises three shapes:
- Command slice:
__init__, command, decider, handler, route, tool. Default for state-changing operations that fold through a pure decider. - Query slice:
__init__, query, handler, route, tool. No decider; reads from the aggregate or a projection. - Entry-append slice (
append_<entry>):__init__, command, handler, route, tool. No decider; the handler writes directly to a typed entries store via a per-category port (ReasoningStore,ReadingStore,StepStore). Today:decision/append_reasoning_entry,run/append_run_reading,operation/append_procedure_step. New entry-append slices must be added to_ENTRY_APPEND_SLICESin the test.
Optional slice files¶
context.py: slice-local cross-aggregate pre-load. Used when a decider needs sibling-aggregate state (e.g.start_runpre-loads Asset, Method, Plan, Practice, Subject before calling the pure decider). Used by 7 slices today acrossdata,decision,recipe,run,subject,operation. Lives in the slice folder, not the aggregate.
BC-root extras¶
_projections.py: composition-root entry point that registers the BC's projections with the projection registry. Mechanical and present in every BC that has aprojections/directory._<aggregate>_update_handler.py: factory that hoists shared update-handler scaffolding when n>=3 update slices on the same aggregate share the pattern (perproject_update_handler_pattern.md). Today: asset, subject, supply, procedure, clearance.authorize_factory.py(trust BC only): exportsbuild_authorize, injected into the kernel by the composition root incora/api/main.py. No other BC imports it.
Aggregate-internal shared modules¶
VOs and validation helpers consumed by the aggregate kernel must live inside the aggregate folder, not at the BC root. Tach treats cora.<bc>.aggregates and cora.<bc> as separate modules and the kernel cannot depend on the parent.
Examples: equipment/aggregates/asset/settings_validation.py, recipe/aggregates/plan/{parameters_validation,wires_validation}.py, safety/aggregates/clearance/hazard_classification.py. Feature slices import them via the longer path (from cora.<bc>.aggregates.<aggregate>.<module> import ...); the layering cost is paid by the consumer, not the kernel.
Imports¶
Prefer package imports (re-exported from __init__.py) over submodule imports:
# Preferred
from cora.access.application import RegisterActorHandler, UnauthorizedError
# Avoid
from cora.access.application.register_actor_handler import RegisterActorHandler
The __init__.py is the BC's curated public surface; importing through it lets the layout reorganize without ripple edits. Submodule paths only when a symbol is intentionally not re-exported. Enforced by review.
Naming¶
- Commands: PascalCase verb+noun in
command.py(e.g.RegisterActor). - Define vs Register:
Define<X>for types/templates/configs (Zone, Conduit, Policy, Family: defined once, referenced as a contract).Register<X>for instances (Actor, Subject, Asset: recorded). Genesis event mirrors the verb (<X>Definedvs<X>Registered). - Queries: PascalCase nouns in
query.py(e.g.GetActor). - Decider: pure
decideindecider.py. Create-style:decide(state, command, *, now, new_id). Update-style:decide(state, command, *, now). - Handler:
bind(deps) -> Handlerinhandler.py. BareHandleris aProtocol; create/update slices that opt into idempotency also defineIdempotentHandler(same shape + optionalidempotency_key). - Domain errors: PascalCase +
Errorsuffix in the aggregate'sstate.py(e.g.InvalidActorNameError). - BC-application errors: PascalCase +
Errorsuffix incora/<bc>/errors.py(e.g.UnauthorizedError). Each BC registers its own handler; same-named errors across BCs are distinct classes. - Domain events: PascalCase past-tense in the aggregate's
events.py(e.g.ActorRegistered). Same file holds the<Aggregate>Eventdiscriminated union.
Bootstrap¶
Constants every slice surface needs but that aren't slice-specific live in cora/<bc>/_bootstrap.py. Today: SYSTEM_PRINCIPAL_ID, canonically in cora/infrastructure/routing.py:
# cora/access/_bootstrap.py
from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID
__all__ = ["SYSTEM_PRINCIPAL_ID"]
MCP tools import from _bootstrap.py (preserves per-BC naming); REST routes pull it indirectly via get_principal_id. The leading underscore signals BC-internal.
Shared code¶
Don't extract until three real usages with identical, stable logic (Rule of Three). Shared primitives (errors, VOs across aggregates) live at the BC root or in _shared/. Cross-BC concerns under cora/infrastructure/.
When the Rule of Three yields to local clarity¶
The 18 aggregates/<aggregate>/read.py files are 7-line near-clones that differ only in the stream-type constant and three import lines. Rule of Three was crossed long ago, but the duplication stays. A generic load_aggregate(event_store, stream_type, from_stored, fold) would save ~3 lines per call site at the cost of an extra parameter-passing chain — the caller still has to import the aggregate-specific from_stored / fold to pass them in. The wrappers are mechanical, stable, and locally legible: opening aggregates/<aggregate>/read.py shows the entire fold-on-read path for that aggregate without a hop. A 19th aggregate doesn't change the answer.
The same posture applies to other "18 mechanical near-clones" surfaces (per-aggregate events.py event_type_name / to_payload / from_stored, evolver fold walker): per-aggregate locality wins over a generic helper that wouldn't actually shrink the call sites.