mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
docs(backend): centralize guides and clarify domain boundaries (#44957)
This commit is contained in:
1 parent
dbde018550
commit
ffce3dafd9
10 files changed
+203
-154
No files matched your search
@@ -5,7 +5,7 @@ description: Use only when the user explicitly requests a review or audit of bac
|
||||
|
||||
# Backend Code Review
|
||||
|
||||
Review the requested scope for concrete, reproducible defects. The nearest `AGENTS.md` owns package facts and commands; this skill owns the review workflow and routes to its bundled rule packs.
|
||||
Review the requested scope for concrete, reproducible defects. The nearest `AGENTS.md` owns package facts and commands; this skill owns the review workflow and routes to the shared backend guides in `docs/`.
|
||||
|
||||
## Evidence First
|
||||
|
||||
@@ -16,14 +16,14 @@ Review the requested scope for concrete, reproducible defects. The nearest `AGEN
|
||||
|
||||
## Rule Routing
|
||||
|
||||
Read only the packs matched by the diff:
|
||||
Read only the guidance matched by the diff:
|
||||
|
||||
- Models or migrations: [`references/db-schema-rule.md`][db-schema]
|
||||
- Controllers, application services, ports, adapters, the composition root, import-boundary contracts, libraries, or model behavior: [`references/architecture-rule.md`][architecture]
|
||||
- Table access outside a repository that implements a consumer-owned port: [`references/repositories-rule.md`][repositories]
|
||||
- SQLAlchemy sessions, queries, transactions, CRUD, concurrency, or raw SQL: [`references/sqlalchemy-rule.md`][sqlalchemy]
|
||||
- Models or migrations: [Database schema][db-schema]
|
||||
- Controllers, application services, ports, adapters, the composition root, import-boundary contracts, libraries, or model behavior: [Backend architecture][architecture]
|
||||
- Table access outside a repository that implements a consumer-owned port: [Repositories][repositories]
|
||||
- SQLAlchemy sessions, queries, transactions, CRUD, concurrency, or raw SQL: [SQLAlchemy][sqlalchemy]
|
||||
|
||||
When no pack applies, review correctness, security, behavior changes, and test evidence directly. Check current official documentation only when local code and contracts do not settle framework or library behavior.
|
||||
When no guide applies, review correctness, security, behavior changes, and test evidence directly. Check current official documentation only when local code and contracts do not settle framework or library behavior.
|
||||
|
||||
## Severity And Output
|
||||
|
||||
@@ -34,7 +34,7 @@ When no pack applies, review correctness, security, behavior changes, and test e
|
||||
|
||||
Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes.
|
||||
|
||||
[architecture]: references/architecture-rule.md
|
||||
[db-schema]: references/db-schema-rule.md
|
||||
[repositories]: references/repositories-rule.md
|
||||
[sqlalchemy]: references/sqlalchemy-rule.md
|
||||
[architecture]: ../../../docs/BACKEND_ARCHITECTURE.md
|
||||
[db-schema]: ../../../docs/BACKEND_DATABASE_SCHEMA.md
|
||||
[repositories]: ../../../docs/BACKEND_REPOSITORIES.md
|
||||
[sqlalchemy]: ../../../docs/BACKEND_SQLALCHEMY.md
|
||||
@@ -1,133 +0,0 @@
|
||||
# Backend architecture
|
||||
|
||||
The backend uses a ports-and-adapters architecture, with application services orchestrating domain aggregates where business complexity warrants them. Each business capability is a slice. Application services own the use cases and declare ports for everything they need from outside. Adapters implement those ports, and a thin transport layer admits requests and translates them. One composition root connects the parts. The [API agent guide][api-guide] requires reading this document before backend changes; this document explains the architecture and how to review it. The legacy execution kernel is migration-only: never propose new files or extracted implementations there.
|
||||
|
||||
This document describes the architecture as it is today. Business rules live in application services that work with plain records, and no slice uses aggregates yet. The [next step](#next-step) section describes how aggregates are introduced.
|
||||
|
||||
## Layers
|
||||
|
||||
Dependencies point toward the application core. Outer layers know about inner ones; inner layers never know about outer ones.
|
||||
|
||||
- **Transport**: admits the caller, validates input, maps it to application inputs, calls one use case, translates application errors into protocol errors, and serializes the result. It holds no business rules and never touches persistence.
|
||||
- **Application core**: application services own use cases and orchestration. Where business complexity warrants it, aggregates hold one business model's state and rules; services depend on aggregates, never the reverse. The core works only with plain values, the admitted caller identity, and the ports services declare. It never depends on the web framework, the ORM, persistence models, configuration, task queues, transport code, or concrete adapters.
|
||||
- **Infrastructure**: repositories and other adapters implement ports over the database, caches, task queues, external services, the execution kernel, and legacy code. They depend on the contracts they implement, never on the transport layer.
|
||||
- **Composition root**: the only place that chooses implementations, reads configuration, and constructs services. Everything else receives its dependencies.
|
||||
- **Persistence models**: describe tables and nothing else.
|
||||
- **Shared support types and utilities**: framework-neutral caller identity and business-agnostic utilities that any layer may use.
|
||||
|
||||
Import-boundary contracts encode these directions and run in lint. Treat a boundary violation as a design problem to fix, not an exception to record.
|
||||
|
||||
## Application services
|
||||
|
||||
An application module documents its boundary and business rules and contains:
|
||||
|
||||
- **Values**: immutable records for results and inputs. Records carry data, not behavior. Each optional field documents what its absence means. A state the use case reasons about is an application-owned enumeration, not a persistence detail.
|
||||
- **Ports**: interfaces owned by the consumer and named for the role they play: persistence for this use case, a read-only query, a gateway to an external system or legacy code, or a narrow capability such as notification, verification, or encryption. Query ports return records. Persistence ports accept and return records today, and aggregates in slices that adopt them. A port's documentation is the contract every adapter honors: owner scope, transaction boundary, the rules it enforces, the concurrency mechanism behind each of those rules, and the errors it raises.
|
||||
- **Errors**: one base error per slice with specific subtypes that carry user-facing messages. Errors carry no protocol status or response shape.
|
||||
- **Services**: receive ports and plain settings at construction. Every use case takes the admitted caller identity first, derives its scope from that identity, enforces business rules, calls ports with explicit owner identifiers, and returns records.
|
||||
|
||||
Split a large business area into shared contracts, ports, one module per use-case group, and adapters instead of one large module. A read with no rule beyond owner scoping may be a query port that a repository implements directly; add a service once the read gains rules.
|
||||
|
||||
## Adapters
|
||||
|
||||
- **Repositories** implement persistence and query ports. Each operation owns its database session: a short read or one bounded write transaction. Every statement filters by the complete owner chain. Rows become records, or aggregates in slices that adopt them, before the session closes, so sessions, persistence models, and lazy relationships never cross a port. A repository depends only on the application contracts it implements.
|
||||
- **Rules in repositories**: a repository enforces a business rule only when the rule must hold atomically with the write. When concurrent writers can change the state the rule reads, it uses a mechanism from the [consistency](#consistency) section. The port documents every rule its adapters enforce.
|
||||
- **Runtime adapters** implement the remaining ports over caches, task queues, enterprise or external services, the execution kernel, or legacy code. They translate third-party failures into application errors.
|
||||
- **Reuse**: one repository may implement several consumer-owned ports. Extend it instead of adding a parallel query path; see the [repositories rule][repositories].
|
||||
|
||||
## Consistency
|
||||
|
||||
Each repository operation runs in its own session, and no transaction spans a whole use case, so a rule that a service checks before a write is not atomic with that write. Moving the check inside the write transaction does not fix this on its own: at the default isolation level, two concurrent transactions can both see the same state and both write.
|
||||
|
||||
For every rule that concurrent writers can break, name the mechanism that enforces it and document it on the port:
|
||||
|
||||
- **A database constraint**, such as a unique or check constraint, translated into the slice's error when it is violated.
|
||||
- **A conditional write**, such as an update guarded by the expected current state or by a version, that treats zero affected rows as a conflict.
|
||||
- **A lock or stricter isolation** held across the check and the write, with a retry or a clear error on conflict.
|
||||
|
||||
A check that reads only values that never change after creation, such as who created a record, needs no extra coordination inside the write transaction, apart from handling a record that was deleted concurrently.
|
||||
|
||||
## Composition root
|
||||
|
||||
Register every service in the composition root and group related services by business area. Construct concrete adapters there, read configuration there, and pass plain values in. Services never build their own dependencies, and nothing outside the root creates repositories or module-level service instances.
|
||||
|
||||
## Transport layer
|
||||
|
||||
A handler:
|
||||
|
||||
1. Declares admission, including role and permission checks, which yields the framework-neutral caller identity.
|
||||
2. Validates the request and maps it into the use case's input record. It resolves protocol conventions here, such as a masked secret that a client echoes back.
|
||||
3. Calls exactly one use case obtained from the composition root.
|
||||
4. Translates application errors into the established protocol errors and keeps the existing status, error code, and message.
|
||||
5. Serializes the returned records with the shared response schemas, following the [API schema guide][schema-guide].
|
||||
|
||||
## Business rules
|
||||
|
||||
- Pass owner identifiers explicitly into every port call. Below the transport layer, never read the tenant or account from framework globals.
|
||||
- Keep external I/O outside open transactions. Materialize what the I/O needs and close the session first.
|
||||
- Trigger side effects after the write commits. The write port returns what the effect needs, and the service triggers it through its own port.
|
||||
- Keep persistence models free of session access, queries, caching, and application behavior. The repository maps application-owned states to primitive columns.
|
||||
- Raise protocol errors and build responses only in the transport layer.
|
||||
- Reach another slice through its contracts and ports, never through its repositories or internals.
|
||||
|
||||
## Boundary contracts
|
||||
|
||||
Every new or migrated slice adds two import-boundary contracts in the same change. One keeps the application module free of framework, persistence, configuration, task-queue, and transport imports. The other keeps the handler from reaching persistence except through the composition root. A slice that adds an aggregate also adds a contract that keeps the aggregate module from importing the slice's service modules, because the other contracts keep infrastructure out of the application core but do not separate services from aggregates. The legacy layering baseline only shrinks: never add an exception, and remove the exception for each dependency a migration eliminates.
|
||||
|
||||
## Migrating legacy code
|
||||
|
||||
Legacy services that manage sessions themselves, handlers that query persistence models, and kernel code that calls services are migration debt. When a change touches them:
|
||||
|
||||
- Move the touched use cases into a slice and delete the replaced code and its tests in the same change. Do not leave forwarding wrappers.
|
||||
- Preserve the public contract: routes, admission order, status codes, error codes and messages, and response fields. Comment deliberate legacy compatibility where it is kept.
|
||||
- Add the slice's boundary contracts and shrink the legacy baseline.
|
||||
|
||||
## Tests
|
||||
|
||||
Test each seam with real objects:
|
||||
|
||||
- **Aggregates**: their behavior and rules, directly, without ports.
|
||||
- **Application services**: in-memory port implementations that assert the owner scope of every call.
|
||||
- **Repositories**: a real lightweight database session, covering owner scoping, ordering, record mapping, and not-found behavior.
|
||||
- **Runtime adapters**: the external contract and failure translation.
|
||||
- **Composition root**: the wiring of each service.
|
||||
- **Handlers**: admission, input mapping, error translation, and the wire contract.
|
||||
|
||||
Do not introduce mock doubles; CI rejects new ones.
|
||||
|
||||
## Business-agnostic libraries
|
||||
|
||||
Shared libraries must not own product policy or orchestration. Move business decisions to their application owner and keep only reusable infrastructure in libraries.
|
||||
|
||||
## Review checks
|
||||
|
||||
Flag each finding with the rule it violates:
|
||||
|
||||
- A handler that touches persistence, queries data, calls legacy code a slice replaced, or decides business policy.
|
||||
- An application module that depends on the framework, persistence, configuration, or task queues, or raises protocol errors.
|
||||
- A port that exposes sessions or persistence models, an aggregate that reaches the transport layer, or an adapter operation that misses an owner-chain filter.
|
||||
- A rule that concurrent writers can break without a named mechanism: a database constraint, a conditional write, or a lock or isolation level held across the check and the write.
|
||||
- A business rule enforced in a repository that its port does not document.
|
||||
- Dependencies chosen outside the composition root.
|
||||
- A new or migrated slice without boundary contracts, a weakened contract, or a new baseline exception.
|
||||
- Persistence models that gain behavior. They never become the domain model.
|
||||
- An aggregate root whose name does not end with Agg, a child entity named as if it were an aggregate, or an aggregate that depends on application services, ports, sessions, persistence models, configuration, or infrastructure.
|
||||
- New code in the legacy execution kernel, or product policy in shared libraries.
|
||||
- Tests that replace ports with mock doubles.
|
||||
|
||||
## Next step
|
||||
|
||||
Records carry no behavior, so business rules collect in application services and sometimes in repositories. Services currently contain rules that can move into aggregates. The next step introduces aggregates for specific business models where business complexity warrants them. SQLAlchemy models never become the domain model; they stay table mappings.
|
||||
|
||||
- **Aggregates**: an aggregate is the domain model for one business model and its consistency boundary. Its root holds the state of the model and of the child entities that exist only inside it, and enforces the invariants and state transitions. It depends only on plain values, never on application services, ports, sessions, persistence models, configuration, or infrastructure.
|
||||
- **Services and aggregates**: both live in the application core but keep separate roles. An application service orchestrates a use case: it loads an aggregate through a repository port, calls its behavior, saves it through the same port, and triggers side effects after the save commits. Services depend on aggregates, and aggregates never depend on services.
|
||||
- **Placement and naming**: aggregates live in the same directory as the services of their business area. Names tell them apart: the aggregate root and its module end with the suffix Agg. Child entities take plain names, because they are not aggregates of their own.
|
||||
- **Ports**: an aggregate repository port accepts and returns aggregates, and a query port returns records. Sessions and SQLAlchemy models never cross either kind. Use cases return records, so the transport layer never receives an aggregate.
|
||||
- **Repositories**: aggregate repositories load and save whole aggregates and map them to and from SQLAlchemy models. Rules that repositories enforce today move into aggregates. Rules that span aggregates, such as uniqueness, stay with the database, as the [consistency](#consistency) section describes.
|
||||
- **Consistency**: a load and the later save run in separate transactions, so a decision made on loaded state can be stale when it is saved. For every invariant that concurrent writers can break, the slice names the mechanism from the [consistency](#consistency) section that protects it. Writing only the difference between the loaded and the changed aggregate keeps concurrent changes to other parts of the aggregate. It is a limited strategy, not a consistency guarantee: it does not protect decisions that rest on state a concurrent writer can change, and two competing transitions still end with the last writer.
|
||||
|
||||
Adopt this slice by slice, starting with services whose rules belong to one business model, and add the aggregate contract from the [boundary contracts](#boundary-contracts) section with the first aggregate in each slice.
|
||||
|
||||
[api-guide]: ../../../../api/AGENTS.md
|
||||
[repositories]: repositories-rule.md
|
||||
[schema-guide]: ../../../../api/controllers/API_SCHEMA_GUIDE.md
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Dify is an open-source platform for building LLM applications, agentic workflows, and RAG pipelines. This monorepo contains the backend API (`api/`), frontend application (`web/`), deployment assets (`docker/`), standalone agent backend (`dify-agent/`), CLI (`cli/`), and end-to-end suite (`e2e/`). Follow the nearest scoped `AGENTS.md` for the files being changed. Apply its guidance within the user's requested scope; explicit user instructions take precedence over workflow defaults.
|
||||
|
||||
Before starting backend development, read the [architecture](docs/BACKEND_ARCHITECTURE.md), [repositories](docs/BACKEND_REPOSITORIES.md), [SQLAlchemy](docs/BACKEND_SQLALCHEMY.md), and [database schema](docs/BACKEND_DATABASE_SCHEMA.md) guides.
|
||||
|
||||
## Repository Gotchas
|
||||
|
||||
- Run backend commands through `uv run --project api <command>`.
|
||||
|
||||
@@ -10,6 +10,8 @@ In terms of licensing, please take a minute to read our short [License and Contr
|
||||
|
||||
## Before you jump in
|
||||
|
||||
Before starting backend development, read the [architecture](docs/BACKEND_ARCHITECTURE.md), [repositories](docs/BACKEND_REPOSITORIES.md), [SQLAlchemy](docs/BACKEND_SQLALCHEMY.md), and [database schema](docs/BACKEND_DATABASE_SCHEMA.md) guides.
|
||||
|
||||
Looking for something to tackle? Browse our [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) and pick one to get started!
|
||||
|
||||
Got a cool new model runtime or tool to add? Open a PR in our [plugin repo](https://github.com/langgenius/dify-plugins) and show us what you've built.
|
||||
|
||||
+7
-7
@@ -20,9 +20,9 @@ Follow the backend developer guide's [test layout and naming requirements](READM
|
||||
Read all backend architecture documentation before modifying backend code:
|
||||
|
||||
- [Backend architecture][architecture]
|
||||
- [Repositories rule][repositories]
|
||||
- [SQLAlchemy rule][sqlalchemy]
|
||||
- [Database schema rule][db-schema]
|
||||
- [Repositories][repositories]
|
||||
- [SQLAlchemy][sqlalchemy]
|
||||
- [Database schema][db-schema]
|
||||
- [API schema guide][schema-guide]
|
||||
|
||||
## Additional Backend Rules
|
||||
@@ -42,8 +42,8 @@ changes, and CI checks the PR against its target revision.
|
||||
- Treat nullable types (`T | None`) strictly as union types; document the semantic meaning for each branch.
|
||||
- Treat default parameter values as introducing distinct function overloads; verify that every added default has demonstrable practical utility and avoids masking unsafe runtime states.
|
||||
|
||||
[architecture]: ../.agents/skills/backend-code-review/references/architecture-rule.md
|
||||
[repositories]: ../.agents/skills/backend-code-review/references/repositories-rule.md
|
||||
[sqlalchemy]: ../.agents/skills/backend-code-review/references/sqlalchemy-rule.md
|
||||
[db-schema]: ../.agents/skills/backend-code-review/references/db-schema-rule.md
|
||||
[architecture]: ../docs/BACKEND_ARCHITECTURE.md
|
||||
[repositories]: ../docs/BACKEND_REPOSITORIES.md
|
||||
[sqlalchemy]: ../docs/BACKEND_SQLALCHEMY.md
|
||||
[db-schema]: ../docs/BACKEND_DATABASE_SCHEMA.md
|
||||
[schema-guide]: controllers/API_SCHEMA_GUIDE.md
|
||||
@@ -60,6 +60,8 @@ Leave `SECRET_KEY` empty to let Dify generate a persistent key in the storage di
|
||||
|
||||
## Testing
|
||||
|
||||
Test real production behavior with real objects. Do not use mocks, `SimpleNamespace`, or other shortcuts that bypass real testing.
|
||||
|
||||
Backend unit-test directories must exactly mirror the production code's relative directory structure, and filenames must match the production filenames with a `test_` prefix. For example, `api/libs/schedule_utils.py` maps to `api/tests/unit_tests/libs/test_schedule_utils.py`.
|
||||
|
||||
Apply the same rule to provider-local suites: `<provider>/src/<package>/module.py` maps to `<provider>/tests/unit_tests/src/<package>/test_module.py`.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Backend architecture
|
||||
|
||||
The backend uses a ports-and-adapters architecture, with application services orchestrating domain aggregates and domain services where business complexity warrants them. Each business capability is a slice. Application services own the use cases and declare ports for everything they need from outside. Adapters implement those ports, and a thin transport layer admits requests and translates them. One composition root connects the parts. The [API agent guide](../api/AGENTS.md) requires reading this document before backend changes; this document explains the architecture and how to review it. The legacy execution kernel is migration-only: never propose new files or extracted implementations there.
|
||||
|
||||
Most existing services are application services: they coordinate use cases, persistence, and other capabilities. Migrated slices primarily work with plain records, so business rules still collect in application services and repositories. The dedicated `api/domain/` package described below is a target for new or extracted domain code; it does not exist yet.
|
||||
|
||||
Class names do not establish architectural roles. For example, [`WorkflowRunAgg`](../api/services/workflow_run_agg.py) coordinates execution, persistence, and publication and depends on concrete repositories and runtime infrastructure. It is an application orchestration component with legacy coupling, not a pure DDD aggregate. Extract its business behavior before moving anything into the domain package; moving or renaming the whole class would not establish the boundary.
|
||||
|
||||
## Layers
|
||||
|
||||
Dependencies point toward the application core. Outer layers know about inner ones; inner layers never know about outer ones.
|
||||
|
||||
- **Transport**: admits the caller, validates input, maps it to application inputs, calls one use case, translates application errors into protocol errors, and serializes the result. It holds no business rules and never touches persistence.
|
||||
- **Application layer**: application services own use cases and orchestration. They work with plain values, the admitted caller identity, domain objects, and the ports they declare. They never depend on the web framework, the ORM, persistence models, configuration, task queues, transport code, or concrete adapters.
|
||||
- **Domain layer**: aggregates, entities, value objects, and domain services own business state, invariants, transitions, and policies. They perform no external I/O and depend on neither the application layer nor infrastructure. The application and domain layers together form the application core; this is a logical boundary, not the legacy `api/core/` directory.
|
||||
- **Infrastructure**: repositories and other adapters implement ports over the database, caches, task queues, external services, the execution kernel, and legacy code. They depend on the contracts they implement, never on the transport layer.
|
||||
- **Composition root**: the only place that chooses implementations, reads configuration, and constructs services. Everything else receives its dependencies.
|
||||
- **Persistence models**: describe tables and nothing else.
|
||||
- **Shared support types and utilities**: framework-neutral caller identity and business-agnostic utilities that any layer may use.
|
||||
|
||||
Import-boundary contracts encode these directions and run in lint. Treat a boundary violation as a design problem to fix, not an exception to record.
|
||||
|
||||
## Application services
|
||||
|
||||
An application module documents its boundary and business rules and contains:
|
||||
|
||||
- **Values**: immutable records for results and inputs. Records carry data, not behavior; they are not DDD entities or value objects merely because they are named `Entity`. Each optional field documents what its absence means. State belongs to the application contract or, when it participates in domain behavior, to the domain model, never to persistence alone.
|
||||
- **Ports**: interfaces owned by the consumer and named for the role they play: persistence for this use case, a read-only query, a gateway to an external system or legacy code, or a narrow capability such as notification, verification, or encryption. Query ports return records. Persistence ports accept and return records today, and aggregates in slices that adopt them. A port's documentation is the contract every adapter honors: owner scope, transaction boundary, the rules it enforces, the concurrency mechanism behind each of those rules, and the errors it raises.
|
||||
- **Errors**: one application error base per slice with specific subtypes that carry user-facing messages. Translate domain failures into these errors at the application boundary when needed to preserve the public contract. Neither application nor domain errors carry protocol status or response shape; domain errors belong to the domain package and never inherit from application errors.
|
||||
- **Services**: receive ports and plain settings at construction. Every use case takes the admitted caller identity first, derives its scope from that identity, obtains the facts needed by domain behavior, invokes that behavior, calls ports with explicit owner identifiers, and returns records. In slices without a domain model, existing business rules remain here until extracted. Once extracted, the domain is the rule's owner; do not duplicate the rule in the service.
|
||||
|
||||
Split a large business area into shared contracts, ports, one module per use-case group, and adapters instead of one large module. A read with no rule beyond owner scoping may be a query port that a repository implements directly; add a service once the read gains rules.
|
||||
|
||||
## Application services and domain services
|
||||
|
||||
An application service implements a use case: it determines which work to perform and in what order. A domain service implements a business operation that does not naturally belong to an entity or value object. Calling several repositories or sending a notification is orchestration, not a reason to introduce a domain service. These roles follow the separation in [Eric Evans's DDD reference](https://www.domainlanguage.com/wp-content/uploads/2016/05/DDD_Reference_2015-03.pdf); the no-I/O domain boundary and application-owned ports are this repository's concrete policy.
|
||||
|
||||
| Concern | Application service | Domain service |
|
||||
| --- | --- | --- |
|
||||
| Responsibility | Coordinate a use case and its success or failure path. | Express a business calculation, decision, or policy without a natural entity owner. |
|
||||
| Inputs | Admitted caller identity and use-case inputs; load other facts through ports. | Domain objects and explicit business facts already obtained by the caller. |
|
||||
| State | Coordinate progress; business state belongs to domain objects when a domain model exists. | No independently persisted identity or lifecycle; operate on supplied state. |
|
||||
| Dependencies | Domain behavior and consumer-owned persistence, query, and gateway ports. | Domain types and pure helpers; no repositories, application services, or I/O ports. |
|
||||
| Persistence | Ask a repository port to load or save; the adapter owns the session and transaction. | No queries, saves, commits, or ORM objects, including through injected interfaces. |
|
||||
| Notifications | Request delivery through a port after a successful commit. | May produce a business result or event value; never deliver it. |
|
||||
|
||||
Keep a rule on its entity or aggregate when that is its natural owner. Use a domain service only for the remaining domain operation, which may involve several domain objects. A named function is sufficient for a small stateless policy; an interface or service class is not mandatory. Merely involving several objects does not make an operation a domain service, and using one does not make writes to several aggregates atomic.
|
||||
|
||||
## Domain model and purity
|
||||
|
||||
In DDD, an **aggregate** is a consistency boundary, not a database aggregation or a collection of infrastructure dependencies:
|
||||
|
||||
- **Entity**: a business object with identity that persists across state changes. Its methods enforce its own valid behavior. A child entity is changed through its aggregate root so the root can protect aggregate-wide invariants.
|
||||
- **Value object**: a business value defined by its attributes, without independent identity. It can validate its invariants and provide business operations; it is not simply an application input or response record.
|
||||
- **Aggregate**: a root entity and any child entities or value objects whose invariants must be maintained together. The root controls changes to the boundary. A single entity can be an aggregate; do not mirror every table relationship or load an entire business area by default.
|
||||
- **Domain service**: a business policy or operation without a natural entity or value-object owner. It can call domain behavior, but must respect aggregate boundaries and cannot bypass their invariants.
|
||||
|
||||
Pure business logic can change an aggregate's in-memory state. Purity here means freedom from external effects and hidden infrastructure dependencies, not that every method must return a new immutable object. Given the same business state and explicit inputs, the decision should be reproducible. Pass in the current time, generated identifiers, membership facts, or policy values when a rule needs them; do not obtain them from clocks, framework globals, configuration, databases, or remote systems inside the domain.
|
||||
|
||||
Aggregates, entities, value objects, and domain services must not query or persist data, manage sessions or transactions, publish database notifications, dispatch signals, enqueue tasks, send email, access caches or storage, or call external APIs. Hiding those operations behind an injected repository, notifier, callback, or event bus does not make them domain logic. Application services obtain the required facts and perform effects through their own ports; adapters implement the technical operations.
|
||||
|
||||
A domain operation may return or record a plain domain event describing a business fact. Recording that value in memory is distinct from publishing it. Application orchestration owns handling the result and arranging delivery after the corresponding state commits. Domain code does not know about dispatchers, delivery retries, or outbox tables. Use a direct result when an event adds no value.
|
||||
|
||||
## Placement and dependencies
|
||||
|
||||
Place pure domain code under `api/domain/<business_area>/`, grouped by business capability. Keep application use cases and their ports under `api/services/`, persistence adapters under `api/repositories/`, and wiring in `api/extensions/ext_application_services.py` and its `application_services/` modules. Existing runtime adapter locations can remain where they are. The new domain package creates an explicit boundary without requiring a rename of the application-service tree.
|
||||
|
||||
For example, a future extraction of workflow comment behavior could use this layout. These domain files are illustrative, not existing modules or required scaffolding:
|
||||
|
||||
```text
|
||||
api/
|
||||
domain/
|
||||
workflow_comments/
|
||||
workflow_comment_agg.py # WorkflowCommentAgg and its owned domain types
|
||||
mention_policy.py # Pure recipient eligibility policy, if needed
|
||||
errors.py # Business failures, if separate errors are needed
|
||||
services/
|
||||
app/
|
||||
workflow_comment_service.py # Use cases, records, and ports
|
||||
workflow_comment_mention_gateway.py # Existing delivery adapter
|
||||
repositories/
|
||||
app/
|
||||
workflow_comment_repository.py # SQLAlchemy mapping and transactions
|
||||
extensions/
|
||||
ext_application_services.py # Composition root
|
||||
```
|
||||
|
||||
Keep an aggregate and its small child types together until separate modules are useful. Root class names end in `Agg`, and their Python modules end in `_agg.py`; child entities use ordinary business names. Name domain services for their policy or operation. Do not create global `aggregates/`, `entities/`, and `services/` buckets that separate one business model across the tree. `api/models/` remains persistence mappings, `api/core/` remains the migration-only execution kernel, and business-agnostic `api/libs/` is not a home for product rules.
|
||||
|
||||
Imports point from application services to domain code. Repositories may import domain types to reconstruct and persist aggregates, as well as the application ports they implement. Domain code never imports `services`, `repositories`, `models`, `controllers`, `extensions`, `configs`, `tasks`, or the legacy `core` package. It uses its own domain types, standard-library values, and pure helpers whose dependencies preserve this boundary. Transport continues to call application use cases and receive records. Keep each business area's domain internals private; another slice reaches it through application contracts and ports.
|
||||
|
||||
The existing [`WorkflowCommentService`](../api/services/app/workflow_comment_service.py) demonstrates application orchestration: it calls a store and then a notifier. In a domain extraction, comment content rules, creator-only edits, and resolution transitions can belong to `WorkflowCommentAgg`; a recipient policy can operate on supplied mention and membership facts if it needs a separate owner. The application service still obtains those facts and coordinates the save and notification, the repository still enforces owner scope and concurrency, and the existing Celery adapter still performs delivery. Do not move the store or notifier into the aggregate.
|
||||
|
||||
## Adapters
|
||||
|
||||
- **Repositories** implement persistence and query ports. Each operation owns its database session: a short read or one bounded write transaction. Every statement filters by the complete owner chain. Rows become records, or aggregates in slices that adopt them, before the session closes, so sessions, persistence models, and lazy relationships never cross a port. A repository depends on the application contracts it implements and the domain types it maps, never on application orchestration.
|
||||
- **Rules in repositories**: a repository enforces a business rule only when the rule must hold atomically with the write. When concurrent writers can change the state the rule reads, it uses a mechanism from the [consistency](#consistency) section. For an extracted domain rule, reuse the domain behavior when evaluating it inside a transaction; do not create a second policy implementation in the adapter. Database constraints and conditional writes remain necessary enforcement mechanisms. The port documents every rule its adapters enforce.
|
||||
- **Runtime adapters** implement the remaining ports over caches, task queues, enterprise or external services, the execution kernel, or legacy code. They translate third-party failures into application errors.
|
||||
- **Reuse**: one repository may implement several consumer-owned ports. Extend it instead of adding a parallel query path; see the [repositories guide](BACKEND_REPOSITORIES.md).
|
||||
|
||||
## Consistency
|
||||
|
||||
Each repository operation runs in its own session, and no transaction spans a whole use case, so a rule that a service checks before a write is not atomic with that write. Moving the check inside the write transaction does not fix this on its own: at the default isolation level, two concurrent transactions can both see the same state and both write.
|
||||
|
||||
For every rule that concurrent writers can break, name the mechanism that enforces it and document it on the port:
|
||||
|
||||
- **A database constraint**, such as a unique or check constraint, translated into the slice's error when it is violated.
|
||||
- **A conditional write**, such as an update guarded by the expected current state or by a version, that treats zero affected rows as a conflict.
|
||||
- **A lock or stricter isolation** held across the check and the write, with a retry or a clear error on conflict.
|
||||
|
||||
A check that reads only values that never change after creation, such as who created a record, needs no extra coordination inside the write transaction, apart from handling a record that was deleted concurrently.
|
||||
|
||||
## Composition root
|
||||
|
||||
Register every application service in the composition root and group related services by business area. Construct concrete adapters there, read configuration there, and pass plain values in. Application services never build their own infrastructure dependencies, and nothing outside the root creates repositories or module-level application-service instances. Pure domain functions and domain objects need no service registration.
|
||||
|
||||
## Transport layer
|
||||
|
||||
A handler:
|
||||
|
||||
1. Declares admission, including role and permission checks, which yields the framework-neutral caller identity.
|
||||
2. Validates the request and maps it into the use case's input record. It resolves protocol conventions here, such as a masked secret that a client echoes back.
|
||||
3. Calls exactly one use case obtained from the composition root.
|
||||
4. Translates application errors into the established protocol errors and keeps the existing status, error code, and message.
|
||||
5. Serializes the returned records with the shared response schemas, following the [API schema guide](../api/controllers/API_SCHEMA_GUIDE.md).
|
||||
|
||||
## Business rules
|
||||
|
||||
- Pass owner identifiers explicitly into every port call. Below the transport layer, never read the tenant or account from framework globals.
|
||||
- Keep external I/O outside open transactions. Materialize what the I/O needs and close the session first.
|
||||
- Trigger side effects after the write commits. The write port returns what the effect needs, and the application service triggers it through its own port. A domain event or in-memory state change alone is not evidence of a successful commit.
|
||||
- Keep persistence models free of session access, queries, caching, and application behavior. The repository maps application- or domain-owned states to primitive columns.
|
||||
- Raise protocol errors and build responses only in the transport layer.
|
||||
- Reach another slice through its contracts and ports, never through its repositories or internals.
|
||||
|
||||
Post-commit dispatch alone does not guarantee delivery: a process can fail between the commit and the notification. When a use case requires durable delivery, persist its delivery intent with the state change in the same repository transaction and dispatch it afterward with retries and idempotency, for example through a transactional outbox. This is an application and infrastructure concern; it adds no database or notification dependency to the domain. Database-specific notification mechanisms also belong to adapters and must respect commit visibility.
|
||||
|
||||
## Boundary contracts
|
||||
|
||||
Every new or migrated slice adds two import-boundary contracts in the same change. One keeps the application module free of framework, persistence, configuration, task-queue, and transport imports. The other keeps the handler from reaching persistence except through the composition root. The legacy layering baseline only shrinks: never add an exception, and remove the exception for each dependency a migration eliminates.
|
||||
|
||||
When the first domain package is introduced, add `domain` to the root packages in `api/.importlinter` and add a domain contract covering aggregates, entities, value objects, and domain services. Forbid application, persistence, framework, configuration, queue, transport, and legacy-kernel dependencies, including indirect imports through helpers. Preserve each slice's application and transport contracts; the domain rule supplements them. Import checks do not detect I/O hidden behind injected capabilities, so review signatures and behavior as well. The package and this enforcement must land together; this document alone does not add lint enforcement.
|
||||
|
||||
## Migrating legacy code
|
||||
|
||||
Legacy services that manage sessions themselves, handlers that query persistence models, and kernel code that calls services are migration debt. When a change touches them:
|
||||
|
||||
- Move the touched use cases into a slice and delete the replaced code and its tests in the same change. Do not leave forwarding wrappers.
|
||||
- Preserve the public contract: routes, admission order, status codes, error codes and messages, and response fields. Comment deliberate legacy compatibility where it is kept.
|
||||
- Add the slice's boundary contracts and shrink the legacy baseline.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow the backend developer guide's [testing guidelines](../api/README.md#testing).
|
||||
|
||||
## Business-agnostic libraries
|
||||
|
||||
Shared libraries must not own product policy or orchestration. Move business decisions to their domain owner, or to the owning application slice until it has a domain model, and keep only business-agnostic support in libraries.
|
||||
|
||||
## Review checks
|
||||
|
||||
Flag each finding with the rule it violates:
|
||||
|
||||
- A handler that touches persistence, queries data, calls legacy code a slice replaced, or decides business policy.
|
||||
- An application module that depends on the framework, persistence, configuration, or task queues, or raises protocol errors.
|
||||
- A port that exposes sessions or persistence models, an aggregate that reaches the transport layer, or an adapter operation that misses an owner-chain filter.
|
||||
- A rule that concurrent writers can break without a named mechanism: a database constraint, a conditional write, or a lock or isolation level held across the check and the write.
|
||||
- A business rule enforced in a repository that its port does not document.
|
||||
- Dependencies chosen outside the composition root.
|
||||
- A new or migrated slice without boundary contracts, a weakened contract, or a new baseline exception.
|
||||
- Persistence models that gain behavior. They never become the domain model.
|
||||
- An aggregate root whose name does not end with `Agg`, a child entity named as if it were an aggregate, or an orchestration component presented as a domain service or aggregate.
|
||||
- Domain code outside its business-area package, or a domain object or service that depends on application services, I/O ports, sessions, persistence models, configuration, or infrastructure. Include notification and event-dispatch dependencies hidden behind interfaces.
|
||||
- New code in the legacy execution kernel, or product policy in shared libraries.
|
||||
- Tests that violate the [backend testing guidelines](../api/README.md#testing).
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Scope
|
||||
- Covers: model/base inheritance, schema boundaries in model properties, tenant-aware schema design, index redundancy checks, dialect portability in models, and cross-database compatibility in migrations.
|
||||
- Does NOT cover: session lifecycle, transaction boundaries, and query execution patterns (handled by `sqlalchemy-rule.md`).
|
||||
- Does NOT cover: session lifecycle, transaction boundaries, and query execution patterns (handled by the [SQLAlchemy guide](BACKEND_SQLALCHEMY.md)).
|
||||
|
||||
## Rules
|
||||
|
||||
+2
-1
@@ -2,13 +2,14 @@
|
||||
|
||||
## Scope
|
||||
- Covers: reaching persistence through consumer-owned ports, reusing existing repositories, and preserving dependency direction between application services and persistence adapters.
|
||||
- Does NOT cover: SQLAlchemy session lifecycle and query-shape specifics (handled by `sqlalchemy-rule.md`), table/schema/migration design (handled by `db-schema-rule.md`), and the overall slice pattern (handled by `architecture-rule.md`).
|
||||
- Does NOT cover: SQLAlchemy session lifecycle and query-shape specifics (handled by the [SQLAlchemy guide](BACKEND_SQLALCHEMY.md)), table/schema/migration design (handled by the [database schema guide](BACKEND_DATABASE_SCHEMA.md)), and the overall slice pattern (handled by the [backend architecture guide](BACKEND_ARCHITECTURE.md)).
|
||||
|
||||
## Rules
|
||||
|
||||
### Reach persistence only through consumer-owned ports
|
||||
- Category: maintainability
|
||||
- Description: Application services never query tables or hold persistence models. Each service declares the persistence it needs as a port in its own module, and a repository implements that port. When a table already has a repository, extend it or let it implement the new port instead of adding a parallel query path.
|
||||
- Domain boundary: Aggregates, entities, value objects, and domain services under `api/domain/<business_area>/` never call repositories, including through abstract ports. Application services load the required facts and save the results. Repository implementations may import domain types for mapping and invoke pure domain behavior when atomic evaluation is needed; they must not duplicate the business policy or depend on application orchestration. See [backend architecture](BACKEND_ARCHITECTURE.md#domain-model-and-purity).
|
||||
- Suggested fix:
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` for an existing repository. If one exists, add the missing operation there and depend on it through the consumer's port.
|
||||
- Otherwise, declare the port next to the service that needs it and implement it in a repository outside migration-only `api/core/`. The repository owns its sessions, filters by the complete owner chain, and is wired only in the composition root. Query ports return plain records; aggregate repository ports, in slices that adopt aggregates, accept and return whole aggregates. Neither returns persistence models or sessions.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Scope
|
||||
- Covers: SQLAlchemy session and transaction lifecycle, query construction, tenant scoping, raw SQL boundaries, and write-path concurrency safeguards.
|
||||
- Does NOT cover: table/model schema and migration design details (handled by `db-schema-rule.md`).
|
||||
- Does NOT cover: table/model schema and migration design details (handled by the [database schema guide](BACKEND_DATABASE_SCHEMA.md)).
|
||||
|
||||
## Rules
|
||||
|
||||
Reference in new issue
Block a user