Skip to content

Commit f826c8e

Browse files
phernandezclaude
andcommitted
refactor: add composition roots for API, MCP, and CLI entrypoints (#492)
Introduces composition roots (containers) that centralize reading ConfigManager and environment variables at entrypoints. This is the foundation for reducing coupling and scattered cloud_mode/config checks throughout the codebase. Changes: - Add `basic_memory/runtime.py` with RuntimeMode enum and resolve_runtime_mode() - Add `api/container.py` with ApiContainer composition root - Add `mcp/container.py` with McpContainer composition root - Add `cli/container.py` with CliContainer composition root - Update API lifespan to use ApiContainer for config and mode decisions - Update MCP lifespan to use McpContainer for config and mode decisions - Update CLI callback to use CliContainer for config access - Update integration test to patch container methods instead of module imports The containers provide: - Single point of config access (only composition roots read ConfigManager) - Runtime mode resolution (cloud/local/test) in one place - Centralized sync/watch decision logic (should_sync_files property) This is step 1 of the refactoring roadmap in issue #490. Future steps will: - Split deps.py into feature modules (#491) - Inject config explicitly rather than reading globally (#496) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent ba1439f commit f826c8e

11 files changed

Lines changed: 603 additions & 36 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from loguru import logger
99

1010
from basic_memory import __version__ as version
11-
from basic_memory import db
11+
from basic_memory.api.container import ApiContainer, set_container
1212
from basic_memory.api.routers import (
1313
directory_router,
1414
importer_router,
@@ -30,7 +30,7 @@
3030
prompt_router as v2_prompt,
3131
importer_router as v2_importer,
3232
)
33-
from basic_memory.config import ConfigManager, init_api_logging
33+
from basic_memory.config import init_api_logging
3434
from basic_memory.services.initialization import initialize_file_sync, initialize_app
3535

3636

@@ -41,35 +41,41 @@ async def lifespan(app: FastAPI): # pragma: no cover
4141
# Initialize logging for API (stdout in cloud mode, file otherwise)
4242
init_api_logging()
4343

44-
app_config = ConfigManager().config
45-
logger.info("Starting Basic Memory API")
44+
# --- Composition Root ---
45+
# Create container and read config (single point of config access)
46+
container = ApiContainer.create()
47+
set_container(container)
48+
app.state.container = container
4649

47-
await initialize_app(app_config)
50+
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
51+
52+
await initialize_app(container.config)
4853

4954
# Cache database connections in app state for performance
5055
logger.info("Initializing database and caching connections...")
51-
engine, session_maker = await db.get_or_create_db(app_config.database_path)
56+
engine, session_maker = await container.init_database()
5257
app.state.engine = engine
5358
app.state.session_maker = session_maker
5459
logger.info("Database connections cached in app state")
5560

56-
# Start file sync if enabled
57-
if app_config.sync_changes and not app_config.is_test_env:
58-
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
61+
# Start file sync if enabled (decision made by container)
62+
if container.should_sync_files:
63+
logger.info(f"Sync changes enabled: {container.config.sync_changes}")
5964

60-
# start file sync task in background
65+
# Start file sync task in background
6166
async def _file_sync_runner() -> None:
62-
await initialize_file_sync(app_config)
67+
await initialize_file_sync(container.config)
6368

6469
app.state.sync_task = asyncio.create_task(_file_sync_runner())
6570
else:
66-
if app_config.is_test_env:
71+
# Log why sync was skipped
72+
if container.mode.is_test:
6773
logger.info("Test environment detected. Skipping file sync service.")
6874
else:
6975
logger.info("Sync changes disabled. Skipping file sync service.")
7076
app.state.sync_task = None
7177

72-
# proceed with startup
78+
# Proceed with startup
7379
yield
7480

7581
logger.info("Shutting down Basic Memory API")
@@ -81,7 +87,7 @@ async def _file_sync_runner() -> None:
8187
except asyncio.CancelledError:
8288
logger.info("Sync task cancelled successfully")
8389

84-
await db.shutdown_db()
90+
await container.shutdown_database()
8591

8692

8793
# Initialize FastAPI app

src/basic_memory/api/container.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""API composition root for Basic Memory.
2+
3+
This container owns reading ConfigManager and environment variables for the
4+
API entrypoint. Downstream modules receive config/dependencies explicitly
5+
rather than reading globals.
6+
7+
Design principles:
8+
- Only this module reads ConfigManager directly
9+
- Runtime mode (cloud/local/test) is resolved here
10+
- Factories for services are provided, not singletons
11+
"""
12+
13+
from dataclasses import dataclass
14+
15+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
16+
17+
from basic_memory import db
18+
from basic_memory.config import BasicMemoryConfig, ConfigManager
19+
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
20+
21+
22+
@dataclass
23+
class ApiContainer:
24+
"""Composition root for the API entrypoint.
25+
26+
Holds resolved configuration and runtime context.
27+
Created once at app startup, then used to wire dependencies.
28+
"""
29+
30+
config: BasicMemoryConfig
31+
mode: RuntimeMode
32+
33+
# --- Database ---
34+
# Cached database connections (set during lifespan startup)
35+
engine: AsyncEngine | None = None
36+
session_maker: async_sessionmaker[AsyncSession] | None = None
37+
38+
@classmethod
39+
def create(cls) -> "ApiContainer":
40+
"""Create container by reading ConfigManager.
41+
42+
This is the single point where API reads global config.
43+
"""
44+
config = ConfigManager().config
45+
mode = resolve_runtime_mode(
46+
cloud_mode_enabled=config.cloud_mode_enabled,
47+
is_test_env=config.is_test_env,
48+
)
49+
return cls(config=config, mode=mode)
50+
51+
# --- Runtime Mode Properties ---
52+
53+
@property
54+
def should_sync_files(self) -> bool:
55+
"""Whether file sync should be started.
56+
57+
Sync is enabled when:
58+
- sync_changes is True in config
59+
- Not in test mode (tests manage their own sync)
60+
"""
61+
return self.config.sync_changes and not self.mode.is_test
62+
63+
# --- Database Factory ---
64+
65+
async def init_database(self) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
66+
"""Initialize and cache database connections.
67+
68+
Returns:
69+
Tuple of (engine, session_maker)
70+
"""
71+
engine, session_maker = await db.get_or_create_db(self.config.database_path)
72+
self.engine = engine
73+
self.session_maker = session_maker
74+
return engine, session_maker
75+
76+
async def shutdown_database(self) -> None:
77+
"""Clean up database connections."""
78+
await db.shutdown_db()
79+
80+
81+
# Module-level container instance (set by lifespan)
82+
# This allows deps.py to access the container without reading ConfigManager
83+
_container: ApiContainer | None = None
84+
85+
86+
def get_container() -> ApiContainer:
87+
"""Get the current API container.
88+
89+
Raises:
90+
RuntimeError: If container hasn't been initialized
91+
"""
92+
if _container is None:
93+
raise RuntimeError("API container not initialized. Call set_container() first.")
94+
return _container
95+
96+
97+
def set_container(container: ApiContainer) -> None:
98+
"""Set the API container (called by lifespan)."""
99+
global _container
100+
_container = container

src/basic_memory/cli/app.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
import typer # noqa: E402
1616

17-
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
17+
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
18+
from basic_memory.config import init_cli_logging # noqa: E402
1819
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
1920

2021

@@ -47,6 +48,11 @@ def app_callback(
4748
# Initialize logging for CLI (file only, no stdout)
4849
init_cli_logging()
4950

51+
# --- Composition Root ---
52+
# Create container and read config (single point of config access)
53+
container = CliContainer.create()
54+
set_container(container)
55+
5056
# Show telemetry notice and track CLI startup
5157
# Skip for 'mcp' command - it handles its own telemetry in lifespan
5258
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
@@ -65,8 +71,7 @@ def app_callback(
6571
):
6672
from basic_memory.services.initialization import ensure_initialization
6773

68-
app_config = ConfigManager().config
69-
ensure_initialization(app_config)
74+
ensure_initialization(container.config)
7075

7176

7277
## import

src/basic_memory/cli/container.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""CLI composition root for Basic Memory.
2+
3+
This container owns reading ConfigManager and environment variables for the
4+
CLI entrypoint. Downstream modules receive config/dependencies explicitly
5+
rather than reading globals.
6+
7+
Design principles:
8+
- Only this module reads ConfigManager directly
9+
- Runtime mode (cloud/local/test) is resolved here
10+
- Different CLI commands may need different initialization
11+
"""
12+
13+
from dataclasses import dataclass
14+
15+
from basic_memory.config import BasicMemoryConfig, ConfigManager
16+
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
17+
18+
19+
@dataclass
20+
class CliContainer:
21+
"""Composition root for the CLI entrypoint.
22+
23+
Holds resolved configuration and runtime context.
24+
Created once at CLI startup, then used by subcommands.
25+
"""
26+
27+
config: BasicMemoryConfig
28+
mode: RuntimeMode
29+
30+
@classmethod
31+
def create(cls) -> "CliContainer":
32+
"""Create container by reading ConfigManager.
33+
34+
This is the single point where CLI reads global config.
35+
"""
36+
config = ConfigManager().config
37+
mode = resolve_runtime_mode(
38+
cloud_mode_enabled=config.cloud_mode_enabled,
39+
is_test_env=config.is_test_env,
40+
)
41+
return cls(config=config, mode=mode)
42+
43+
# --- Runtime Mode Properties ---
44+
45+
@property
46+
def is_cloud_mode(self) -> bool:
47+
"""Whether running in cloud mode."""
48+
return self.mode.is_cloud
49+
50+
51+
# Module-level container instance (set by app callback)
52+
_container: CliContainer | None = None
53+
54+
55+
def get_container() -> CliContainer:
56+
"""Get the current CLI container.
57+
58+
Returns:
59+
The CLI container
60+
61+
Raises:
62+
RuntimeError: If container hasn't been initialized
63+
"""
64+
if _container is None:
65+
raise RuntimeError("CLI container not initialized. Call set_container() first.")
66+
return _container
67+
68+
69+
def set_container(container: CliContainer) -> None:
70+
"""Set the CLI container (called by app callback)."""
71+
global _container
72+
_container = container
73+
74+
75+
def get_or_create_container() -> CliContainer:
76+
"""Get existing container or create new one.
77+
78+
This is useful for CLI commands that might be called before
79+
the main app callback runs (e.g., eager options).
80+
"""
81+
global _container
82+
if _container is None:
83+
_container = CliContainer.create()
84+
return _container

src/basic_memory/mcp/container.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""MCP composition root for Basic Memory.
2+
3+
This container owns reading ConfigManager and environment variables for the
4+
MCP server entrypoint. Downstream modules receive config/dependencies explicitly
5+
rather than reading globals.
6+
7+
Design principles:
8+
- Only this module reads ConfigManager directly
9+
- Runtime mode (cloud/local/test) is resolved here
10+
- File sync decisions are centralized here
11+
"""
12+
13+
from dataclasses import dataclass
14+
15+
from basic_memory.config import BasicMemoryConfig, ConfigManager
16+
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
17+
18+
19+
@dataclass
20+
class McpContainer:
21+
"""Composition root for the MCP server entrypoint.
22+
23+
Holds resolved configuration and runtime context.
24+
Created once at server startup, then used to wire dependencies.
25+
"""
26+
27+
config: BasicMemoryConfig
28+
mode: RuntimeMode
29+
30+
@classmethod
31+
def create(cls) -> "McpContainer":
32+
"""Create container by reading ConfigManager.
33+
34+
This is the single point where MCP reads global config.
35+
"""
36+
config = ConfigManager().config
37+
mode = resolve_runtime_mode(
38+
cloud_mode_enabled=config.cloud_mode_enabled,
39+
is_test_env=config.is_test_env,
40+
)
41+
return cls(config=config, mode=mode)
42+
43+
# --- Runtime Mode Properties ---
44+
45+
@property
46+
def should_sync_files(self) -> bool:
47+
"""Whether local file sync should be started.
48+
49+
Sync is enabled when:
50+
- sync_changes is True in config
51+
- Not in test mode (tests manage their own sync)
52+
- Not in cloud mode (cloud handles sync differently)
53+
"""
54+
return (
55+
self.config.sync_changes and not self.mode.is_test and not self.mode.is_cloud
56+
)
57+
58+
@property
59+
def sync_skip_reason(self) -> str | None:
60+
"""Reason why sync is skipped, or None if sync should run.
61+
62+
Useful for logging why sync was disabled.
63+
"""
64+
if self.mode.is_test:
65+
return "Test environment detected"
66+
if self.mode.is_cloud:
67+
return "Cloud mode enabled"
68+
if not self.config.sync_changes:
69+
return "Sync changes disabled"
70+
return None
71+
72+
73+
# Module-level container instance (set by lifespan)
74+
_container: McpContainer | None = None
75+
76+
77+
def get_container() -> McpContainer:
78+
"""Get the current MCP container.
79+
80+
Raises:
81+
RuntimeError: If container hasn't been initialized
82+
"""
83+
if _container is None:
84+
raise RuntimeError("MCP container not initialized. Call set_container() first.")
85+
return _container
86+
87+
88+
def set_container(container: McpContainer) -> None:
89+
"""Set the MCP container (called by lifespan)."""
90+
global _container
91+
_container = container

0 commit comments

Comments
 (0)