deer-flow/backend/app/composition.py
rayhpeng 8e07bd0159 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.
2026-07-29 18:29:04 +08:00

70 lines
2.7 KiB
Python

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