mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +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.
|
# Initialize repositories — one get_session_factory() call for all.
|
||||||
sf = get_session_factory()
|
sf = get_session_factory()
|
||||||
if sf is not None:
|
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
|
from deerflow.persistence.run import RunRepository
|
||||||
|
|
||||||
app.state.run_store = RunRepository(sf)
|
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:
|
else:
|
||||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
|
||||||
app.state.run_store = 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
|
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