Skip to content

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.

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 = true
forbid_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",
]

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 PublicCommand

Consumers do not import implementation files:

from <package>.contexts.<context>.domain.<feature>.models import PublicCommand

The second form must fail tach check --dependencies --interfaces.

  1. Add the context-level rules first.
  2. Move root domain/*.py files into named domain feature packages.
  3. Add public interfaces for each moved feature package.
  4. Only then tighten application-to-infrastructure through ports. Do not hide direct repository imports with ad hoc exceptions.

Run Tach as an architecture linter, not as a test helper:

Terminal window
uv run --group dev tach check --dependencies --interfaces

The normal lint target should include Tach next to Ruff.

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 artifact

tests/ 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.py
backend/src/fractalops/contexts/access/application/studio/features/*/*_test.py
backend/src/fractalops/contexts/access/presentation/*_test.py
backend/src/fractalops/contexts/access/application/events/*_test.py
backend/src/fractalops/contexts/access/application/integration/*_test.py
backend/src/fractalops/contexts/codexgate/application/*_test.py
backend/src/fractalops/contexts/evidence/application/*_test.py
backend/src/fractalops/contexts/identity/application/*_test.py
backend/src/fractalops/contexts/policy/application/*_test.py
backend/src/fractalops/contexts/project_factory/application/*_test.py
backend/src/fractalops/contexts/connectors/application/*_test.py
backend/src/fractalops/contexts/orchestration/application/*_test.py
backend/src/fractalops/contexts/connectors/infrastructure/executors/*_test.py

If 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 cases

Do 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.py
ops/infra/*_test.py
ops/cli/*_test.py

ops/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.py
ops/cli/ouroboros_run_finish.py
ops/cli/ouroboros_turn_run_context.py
ops/cli/ouroboros_turn_work_items.py
ops/cli/ouroboros_turn_execution_orchestration.py
-> source-owned use-case and policy slices
ops/cli/ouroboros_*_test.py
-> contracts for those slices

Do 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.

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 policy

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-astro keeps .astro files in the lint surface.
  • eslint-plugin-boundaries mirrors the same feature-slice idea for atoms -> molecules -> organisms -> templates, feature api/model/store/ui, pages, actions, and server-only libraries.
  • no-restricted-imports keeps shared frontend state on Nano Stores and blocks app-local Vite/Tailwind integration drift.

The split is deliberate:

backend/domain architecture -> Tach
frontend/Astro atomic slices -> ESLint boundaries
kubernetes/GitOps desired state -> platform/k8s renderers and generated-artifact guardrails

The local gate is split from the full architecture gate:

  • make lint runs fast file-level checks: Ruff plus cached frontend ESLint.
  • make lint-changed runs the staged-file path through lint-staged.
  • make lint-full adds 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.