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:
rayhpeng 2026-07-29 18:29:04 +08:00
parent 793169529c
commit 8e07bd0159
3 changed files with 100 additions and 10 deletions

View 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),
),
)

View File

@ -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

View 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