mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-30 01:46:01 +00:00
refactor(app): extract the composition root into a pure function
Wiring moves out of deps.py::langgraph_runtime into app/composition.py::build_domain_services, so the rule the assembly owns -- a memory database backend yields no services and the routes answer 503 -- is an assertion in tests/test_composition.py instead of a comment inside the lifespan.
This commit is contained in:
parent
793169529c
commit
8e07bd0159
69
backend/app/composition.py
Normal file
69
backend/app/composition.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Composition root -- the one place adapters are instantiated and wired.
|
||||
|
||||
Every application service is assembled here and nowhere else. Ports are
|
||||
declared by the domain, implemented under ``app/adapters/``, and the two are
|
||||
introduced to each other in this file; no router, middleware, or lifespan hook
|
||||
constructs an adapter of its own.
|
||||
|
||||
**Why this is a pure function rather than part of the lifespan.** Wiring used
|
||||
to live inline in ``deps.py::langgraph_runtime``, tangled with engine startup
|
||||
and shutdown -- so the one rule that actually governs it ("no SQL backend
|
||||
means no service, and the routes answer 503") could not be tested without
|
||||
driving a full application startup. ``build_domain_services`` takes what it
|
||||
needs and returns what it built, so that rule is an assertion in
|
||||
``tests/test_composition.py`` instead.
|
||||
|
||||
**What "no SQL backend" means.** ``session_factory is None`` is how a
|
||||
``database.backend: memory`` deployment presents itself. The feedback context
|
||||
owns a table, so it cannot run on it; the service is ``None`` and the
|
||||
dependency provider translates that into 503. This is deliberately not a
|
||||
silent degradation to an in-memory implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deerflow.domain.feedback import FeedbackService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.runtime.runs.store import RunStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainServices:
|
||||
"""Every application service the Gateway serves, or ``None`` where the
|
||||
configured backend cannot support one."""
|
||||
|
||||
feedback: FeedbackService | None
|
||||
|
||||
|
||||
def build_domain_services(
|
||||
*,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None,
|
||||
run_store: RunStore,
|
||||
) -> DomainServices:
|
||||
"""Wire the domain services from already-built infrastructure.
|
||||
|
||||
Takes infrastructure rather than building it: engines and stores have
|
||||
lifecycles (startup, recovery, shutdown) that belong to the lifespan,
|
||||
while deciding *what is assembled from them* is this function's only
|
||||
job. That split is what makes it callable from a test.
|
||||
"""
|
||||
if session_factory is None:
|
||||
return DomainServices(feedback=None)
|
||||
|
||||
# Imported here, not at module scope: these pull in SQLAlchemy, and the
|
||||
# composition root is imported by tests that only want the memory branch.
|
||||
from app.adapters.feedback.feedback_repository import SqlFeedbackRepository
|
||||
from app.adapters.feedback.run_lookup import RunStoreRunLookup
|
||||
|
||||
return DomainServices(
|
||||
feedback=FeedbackService(
|
||||
repository=SqlFeedbackRepository(session_factory),
|
||||
runs=RunStoreRunLookup(run_store),
|
||||
),
|
||||
)
|
||||
@ -400,23 +400,21 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
|
||||
# Initialize repositories — one get_session_factory() call for all.
|
||||
sf = get_session_factory()
|
||||
if sf is not None:
|
||||
from app.adapters.feedback.feedback_repository import SqlFeedbackRepository
|
||||
from app.adapters.feedback.run_lookup import RunStoreRunLookup
|
||||
from deerflow.persistence.run import RunRepository
|
||||
|
||||
app.state.run_store = RunRepository(sf)
|
||||
# Hexagonal feedback slice: the service (input port) is wired with
|
||||
# its SQL adapter here — the composition root is the only place
|
||||
# adapters are instantiated.
|
||||
app.state.feedback_service = FeedbackService(
|
||||
repository=SqlFeedbackRepository(sf),
|
||||
runs=RunStoreRunLookup(app.state.run_store),
|
||||
)
|
||||
else:
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
|
||||
app.state.run_store = MemoryRunStore()
|
||||
app.state.feedback_service = None # memory backend → 503, as before
|
||||
|
||||
# Composition root: adapters are instantiated only inside
|
||||
# build_domain_services; a memory backend yields None services and
|
||||
# the dependency providers translate that into 503.
|
||||
from app.composition import build_domain_services
|
||||
|
||||
domain_services = build_domain_services(session_factory=sf, run_store=app.state.run_store)
|
||||
app.state.feedback_service = domain_services.feedback
|
||||
|
||||
from deerflow.persistence.thread_meta import make_thread_store
|
||||
|
||||
|
||||
23
backend/tests/test_composition.py
Normal file
23
backend/tests/test_composition.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Composition-root tests -- the rules the wiring itself owns.
|
||||
|
||||
``build_domain_services`` was extracted into a pure function precisely so
|
||||
these rules are assertions instead of a comment inside the lifespan:
|
||||
a ``database.backend: memory`` deployment gets no services (the dependency
|
||||
providers translate that into 503).
|
||||
"""
|
||||
|
||||
from app.composition import build_domain_services
|
||||
|
||||
|
||||
class TestMemoryBackend:
|
||||
def test_no_sql_backend_means_no_services(self):
|
||||
services = build_domain_services(session_factory=None, run_store=object())
|
||||
assert services.feedback is None
|
||||
|
||||
|
||||
class TestSqlBackend:
|
||||
def test_services_are_assembled(self):
|
||||
# A bare object suffices: the factory is only handed to adapters,
|
||||
# never called during assembly.
|
||||
services = build_domain_services(session_factory=object(), run_store=object())
|
||||
assert services.feedback is not None
|
||||
Loading…
x
Reference in New Issue
Block a user