Tach DDD / Feature Slice Boundary Standard
Tach DDD / Feature Slice Boundary Standard
Section titled “Tach DDD / Feature Slice Boundary Standard”This is the FractalOps architecture linting standard for Python solutions. It is intended to be reusable across FractalOps-owned codebases such as PlaywrightGrid, SearXNGGrid, agent-memory, and other internal MCP services.
The goal is not to encode product-specific names. The goal is to enforce the same bounded-context topology everywhere:
src/<package>/contexts/<context>/ domain/ application/ infrastructure/ or adapters/ presentation/DDD and feature slice are different axes:
- DDD bounded context: the macro semantic boundary.
- Feature slice: the workflow/module boundary inside a context.
- Hexagonal layering: the dependency direction.
The resulting structure is:
context/ domain/<feature>/ application/<feature>/ infrastructure/<feature>/ presentation/<feature>/Root-level files in domain/ are migration debt. New domain code should live
under a named feature package and be imported through that package’s public
interface.
Required Tach Rules
Section titled “Required Tach Rules”Every Python solution should start with these rules, adjusted only for the package import root:
source_roots = ["backend/src"]root_module = "ignore"ignore_type_checking_imports = trueforbid_circular_dependencies = true
[[modules]]path = "<package>.contexts.*.infrastructure"cannot_depend_on = ["<package>.contexts.*.presentation"]
[[modules]]path = "<package>.contexts.*.adapters"cannot_depend_on = ["<package>.contexts.*.presentation"]
[[modules]]path = "<package>.contexts.*.application"cannot_depend_on = ["<package>.contexts.*.presentation"]
[[modules]]path = "<package>.contexts.*.domain"depends_on = [ "<package>.contexts.*.domain", "<package>.foundation.**",]cannot_depend_on = [ "<package>.contexts.*.application", "<package>.contexts.*.infrastructure", "<package>.contexts.*.presentation", "<package>.contexts.*.adapters",]Feature Public Interfaces
Section titled “Feature Public Interfaces”When a domain feature is split into a package, define a Tach module and a public interface for it:
[[modules]]path = "<package>.contexts.<context>.domain.<feature>"depends_on = ["<package>.foundation.**"]
[[interfaces]]from = ["<package>.contexts.<context>.domain.<feature>"]expose = [ "PublicCommand", "PublicEvent", "public_decision_function",]Consumers import from the feature package:
from <package>.contexts.<context>.domain.<feature> import PublicCommandConsumers do not import implementation files:
from <package>.contexts.<context>.domain.<feature>.models import PublicCommandThe second form must fail tach check --dependencies --interfaces.
Adoption Order
Section titled “Adoption Order”- Add the context-level rules first.
- Move root
domain/*.pyfiles into named domain feature packages. - Add public interfaces for each moved feature package.
- Only then tighten application-to-infrastructure through ports. Do not hide direct repository imports with ad hoc exceptions.
CI Gate
Section titled “CI Gate”Run Tach as an architecture linter, not as a test helper:
uv run --group dev tach check --dependencies --interfacesThe normal lint target should include Tach next to Ruff.
Test-To-Source Alignment
Section titled “Test-To-Source Alignment”Tests must pin named source decisions, not rebuild runtime behavior inside test doubles. When a wall, blocker, handoff, or delivery decision can be made without IO, put that policy in a named domain or application function and test that function directly.
Place tests next to the source slice that owns the decision:
backend/src/fractalops/contexts/<context>/domain/*_test.py -> pure rules and value decisions
backend/src/fractalops/contexts/<context>/application/<feature>/*_test.py -> use-case flow and source-owned policies
backend/src/fractalops/contexts/<context>/infrastructure/<adapter>/*_contract_test.py -> adapter/provider promises
backend/src/fractalops/architecture_test.py -> Tach, import, and size rules
ops/<owned-artifact>/*_test.py -> build/deploy/runtime artifact contracts owned by that artifacttests/ is quarantine for broad system, architecture, integration, e2e, and
migration debt. tests/application and tests/domain are retired: backend
application and domain behavior belong beside the owning source slice. Do not add
a large fake service to prove a small policy branch. First extract the branch
into source, then test the source-owned policy beside that source. This keeps TDD
from becoming a second implementation that drifts away from production code.
tests/contracts/api is retired for the same reason. API route contracts belong
beside the presentation module that owns the route. If the assertion is about
contexts/identity/presentation/scim.py, the test lives as
contexts/identity/presentation/scim_route_test.py. If the assertion is about
shared edge behavior such as tenant headers, request IDs, HTTP JSON, or edge
principals, place it under foundation/presentation/*_test.py.
Root contract files must stay small and cross-cutting. If a root contract grows
past 1200 lines or mostly names one source owner, split it beside that owner and
delete the root file. Use source-local *_test_support.py helpers when needed;
do not rebuild one giant fixture module under tests/.
tests/contracts/system is not a parking lot for small helper contracts. Keep
only contracts that genuinely span multiple bounded contexts or platform planes.
Foundation helpers (value_parsing, pathquery, resource_collections,
endpoints, group_paths), access wizard policies, repository-dispatch
contracts, and other single-owner rules must live beside their source module.
This keeps root tests from becoming a stale map of old paths.
make test-unit collects both backend/src and tests. make simplify-audit
rejects newly added backend test files under tests/, while still allowing
existing legacy tests to be edited or deleted during migration.
Source-colocated tests must be self-contained inside backend/src. They may use
small source-owned test fixtures such as
fractalops.testing.sqlite_session or
fractalops.contexts.access.application.studio.testing.project_case, but they
must not import from tests.*. Importing root test helpers recreates the same
split-brain problem under a different path: source tests then pass only when the
legacy quarantine is also on PYTHONPATH.
If a source-colocated test needs a database, use the source-owned
fractalops.testing.sqlite_session.sqlite_session() context manager or a
context-specific wrapper around it. Do not depend on tests/conftest.py
fixtures such as db_session; those fixtures intentionally belong to the
legacy quarantine and are not part of the source package contract.
Source-colocated pytest bootstrap belongs under backend/src, for example
backend/src/conftest.py. Keep that bootstrap limited to source-package-safe
test invariants such as environment defaults or local no-network WORM storage
fakes. Do not import from root tests/conftest.py, and do not add product
behavior there. If a colocated test requires domain behavior, extract a named
source policy and test that policy instead of growing the bootstrap.
When migrating a large root contract file, delete the root file once the behavior has named source slices. Keep a naming/architecture contract that asserts the obsolete root file is absent and the source slice tests exist.
For FractalOps Studio specifically, tests/application/access/studio/** and
tests/application/studio/** are no longer valid homes for application policy
tests. Studio tests now live beside the owning source slice, for example:
backend/src/fractalops/contexts/access/application/studio/*_test.pybackend/src/fractalops/contexts/access/application/studio/features/*/*_test.pybackend/src/fractalops/contexts/access/presentation/*_test.pybackend/src/fractalops/contexts/access/application/events/*_test.pybackend/src/fractalops/contexts/access/application/integration/*_test.pybackend/src/fractalops/contexts/codexgate/application/*_test.pybackend/src/fractalops/contexts/evidence/application/*_test.pybackend/src/fractalops/contexts/identity/application/*_test.pybackend/src/fractalops/contexts/policy/application/*_test.pybackend/src/fractalops/contexts/project_factory/application/*_test.pybackend/src/fractalops/contexts/connectors/application/*_test.pybackend/src/fractalops/contexts/orchestration/application/*_test.pybackend/src/fractalops/contexts/connectors/infrastructure/executors/*_test.pyIf a future Studio behavior is too broad for one of those locations, split the source responsibility first. Do not recreate a root Studio application test as a coordination shortcut.
Infrastructure repository tests follow the same owner rule. If a facade grows
large, extract aggregate-specific repositories beside it (studio_*_repository.py)
and place *_repository_test.py next to that aggregate. The facade may preserve
the public method name, but it must delegate; duplicate persistence logic in the
facade is migration debt.
For identity and authorization work, name the owner before writing the test:
identity/application/*_test.py -> workload identity, stack identity contracts, SCIM, identity projection
policy/application/*_test.py -> credential issuance, relation authorization, governance decisions
access/application/integration/*_test.py -> portal-facing identity links, principal scope, external integration glue
orchestration/application/*_test.py -> job handlers that invoke identity/access/policy use casesDo not group those tests under tests/application/identity/** just because the
feature mentions identity. That root folder hides the real owner and turns TDD
into a disconnected assertion pile. A TDD test must name the source decision it
drives: domain rule, application use case, adapter contract, or presentation
assembly.
Operational artifacts follow the same colocation rule. A Dockerfile, build shim, or deployment script should carry its contract next to the artifact when the assertion is about that artifact’s content. Do not keep those checks in a Studio/backend contract file just because the runtime eventually uses the artifact.
Examples:
platform/k8s/*_test.pyops/infra/*_test.pyops/cli/*_test.pyops/cli is source, not a dumping ground for scripts. If a CLI controller grows
large, extract named use-case slices beside it and keep the public command or
private wrapper as orchestration only. The colocated test should exercise the
named slice, not a copy of the whole controller flow.
For example, an Ouroboros loop should evolve like this:
ops/cli/run_ouroboros_loop.py -> thin CLI entrypoint and compatibility wrappers
ops/cli/ouroboros_run_bootstrap.pyops/cli/ouroboros_run_finish.pyops/cli/ouroboros_turn_run_context.pyops/cli/ouroboros_turn_work_items.pyops/cli/ouroboros_turn_execution_orchestration.py -> source-owned use-case and policy slices
ops/cli/ouroboros_*_test.py -> contracts for those slicesDo not create tests/application/ouroboros/** or a mega
tests/contracts/studio/test_ouroboros_loop.py to compensate for an oversized
CLI file. Name the behavior first, move it beside ops/cli, and test that
source-owned name. This keeps TDD in control of production design instead of
letting tests fossilize an accidental orchestration shape.
Private Wrapper Drift
Section titled “Private Wrapper Drift”Root tests must not pin a service private wrapper when the source decision already has a named function. The wrapper is orchestration glue; the policy belongs to the source slice.
Bad:
tests/application/portal/launch/test_portal_launch_service.py -> PortalLaunchService._ensure_launch_branch(...)
tests/application/runtime/native_operations/test_native_operations_policy.py -> native_operations._dokploy_api_base(...)Good:
backend/src/fractalops/contexts/access/application/integration/ portal_launch_repository_branch_test.py -> ensure_portal_launch_repository_branch(...)
backend/src/fractalops/contexts/access/application/integration/ native_ops_dokploy_support_test.py -> dokploy_api_base(...)When a root test reaches through a private method, first ask whether the source already names the behavior. If it does, move the test next to that source and delete the root duplicate. If it does not, extract a small source-owned policy or use-case function before writing the test. This prevents TDD from preserving the service’s accidental shape instead of the product rule.
Portal and runtime examples follow the same rule:
portal_redirect_policy.py -> safe continue URL and sign-out redirect policy
portal_launch_repository_branch.py -> GitHub launch branch creation and default-branch soft-fail policy
daytona_workspace_otel_config.py -> Daytona workspace OTEL payload/env policy
native_ops_dokploy_support.py -> Dokploy API/ensure/transport execution policyFrontend Companion Gate
Section titled “Frontend Companion Gate”Tach owns Python/backend boundaries. Portal frontend boundaries are enforced by the OSS ESLint stack because Astro/React source needs framework-aware parsing:
eslint-plugin-astrokeeps.astrofiles in the lint surface.eslint-plugin-boundariesmirrors the same feature-slice idea foratoms -> molecules -> organisms -> templates, featureapi/model/store/ui, pages, actions, and server-only libraries.no-restricted-importskeeps shared frontend state on Nano Stores and blocks app-local Vite/Tailwind integration drift.
The split is deliberate:
backend/domain architecture -> Tachfrontend/Astro atomic slices -> ESLint boundarieskubernetes/GitOps desired state -> platform/k8s renderers and generated-artifact guardrailsThe local gate is split from the full architecture gate:
make lintruns fast file-level checks: Ruff plus cached frontend ESLint.make lint-changedruns the staged-file path throughlint-staged.make lint-fulladds the full Tach dependency/interface gate.
That keeps the developer loop file-scoped while preserving the full cross-solution architecture contract for PR and merge gates.