diff --git a/backend/app/composition.py b/backend/app/composition.py new file mode 100644 index 000000000..4a0fbefe3 --- /dev/null +++ b/backend/app/composition.py @@ -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), + ), + ) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 3f758203a..7848af3be 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -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 diff --git a/backend/tests/test_composition.py b/backend/tests/test_composition.py new file mode 100644 index 000000000..25ce6918d --- /dev/null +++ b/backend/tests/test_composition.py @@ -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