# 0015. Modular monolith package architecture ## Status Proposed ## Context The repository currently contains only a small FastAPI-oriented scaffold under `src/`, while the ADRs define several substantial capabilities and external boundaries: FastAPI HTTP endpoints, Postgres and Alembic, MinIO object storage, RabbitMQ with a transactional outbox, Qdrant point/retrieval operations, LangGraph conversational execution, and separate worker processes. Without an explicit package structure, implementation can drift toward route handlers that call SDK clients directly, workers that duplicate HTTP logic, and generic catch-all directories such as `utils`, `services`, or `clients`. That would make tenant isolation, transactions, retries, resource ownership, and tests harder to apply consistently. ADR-0012 requires application-lifetime resource ownership and explicit dependency passing. ADR-0014 requires workers to own ingestion-generated Chunk/Point CRUD, while direct point routes remain synchronous API work. The repository structure must support both entrypoints reusing the same application behavior without coupling worker code to FastAPI routes or LangGraph nodes to SDK details. ## Decision Use a **modular monolith with explicit infrastructure adapters**. The application uses one deployable codebase with separate web, worker, and outbox-publisher process entrypoints. Business/application behavior is grouped by capability; external systems are isolated behind infrastructure adapters. Use `src/` as the explicit Python package root. ### Package layout Create packages as they become necessary, following this structure: ```text src/ ├── __init__.py ├── main.py ├── config.py ├── bootstrap/ │ ├── lifespan.py │ └── dependencies.py ├── api/ │ ├── dependencies/ │ ├── routers/ │ ├── schemas/ │ └── router.py ├── application/ │ ├── files/ │ ├── ingestion/ │ ├── points/ │ ├── retrieval/ │ ├── threads/ │ └── ports/ ├── agent/ │ ├── graph.py │ ├── state.py │ ├── nodes/ │ ├── prompts/ │ ├── tools/ │ └── persistence.py ├── infrastructure/ │ ├── postgres/ │ │ ├── models/ │ │ ├── repositories/ │ │ ├── database.py │ │ └── outbox.py │ ├── qdrant/ │ ├── minio/ │ ├── rabbitmq/ │ ├── langgraph/ │ └── observability/ ├── messaging/ │ ├── events.py │ ├── subjects.py │ └── outbox_publisher.py └── workers/ ├── ingestion.py └── maintenance.py ``` Keep Alembic configuration and migration revisions at the repository root: ```text alembic.ini alembic/ ├── env.py └── versions/ ``` Keep tests outside the application package and organize them by testing boundary: ```text tests/ ├── unit/ │ ├── application/ │ └── agent/ ├── integration/ │ ├── postgres/ │ ├── minio/ │ ├── rabbitmq/ │ └── qdrant/ └── e2e/ ``` ### Dependency direction Entry adapters call application services; application services depend on typed ports/contracts; infrastructure packages implement those ports. ```text FastAPI routes / RabbitMQ workers / LangGraph nodes -> application services -> application ports -> infrastructure adapters ``` The reverse direction is prohibited: - infrastructure adapters do not import FastAPI routers, workers, or graph nodes; - workers do not call FastAPI route functions; - LangGraph nodes do not call API route functions or construct mutable SDK clients; - application services do not import concrete MinIO, RabbitMQ, Qdrant, or SQLAlchemy client construction code; - routes do not call raw Qdrant, MinIO, or RabbitMQ SDK methods directly. Use ports selectively for external side effects and persistence boundaries; do not add interfaces around pure local functions merely to satisfy a pattern. ### API package `api/` is the HTTP adapter only: - `routers/` map HTTP operations to application-service calls; - `dependencies/` resolve request-lifetime objects such as `AuthContext` and `AsyncSession`; - `schemas/` contains public Pydantic request/response/error models; - `router.py` composes versioned route groups. API schemas are separate from SQLAlchemy ORM models and RabbitMQ message schemas. Routes perform HTTP validation and response mapping, but not parsing, embedding, Qdrant mutations, or transaction-independent business workflows. ### Application package `application/` contains reusable use-case behavior. It has no FastAPI request objects, RabbitMQ consumer loops, or SDK client construction. - `files/` creates source-file records, validates lifecycle actions, and returns file/job status. - `ingestion/` performs parse/chunk/embed/index orchestration after the worker receives a job. - `points/` applies tenant-aware direct Point CRUD rules and shared generated-point mutation behavior. - `retrieval/` owns retrieval use cases used by the graph; it does not expose raw Qdrant SDK details. - `threads/` coordinates run-level application behavior without taking ownership of conversation/session records reserved for the main backend and LangGraph. - `ports/` defines narrow contracts for external side effects, including object storage, message publishing, point storage, and repositories where useful. Both FastAPI routes and worker consumers call these services. This prevents a second, inconsistent ingestion implementation from growing inside `workers/`. ### Agent package All LangGraph-specific application graph code lives in `agent/`: - `graph.py` builds and compiles the graph from explicitly passed dependencies; - `state.py` defines graph state and graph-facing result types; - `nodes/` contains focused graph-node behavior such as triage, retrieval, generation, verification, and memory extraction; - `prompts/` holds prompt identifiers/templates or prompt access helpers; - `tools/` contains graph tool definitions; - `persistence.py` contains graph-facing persistence configuration/types. Concrete `AsyncPostgresSaver` and `AsyncPostgresStore` setup belongs in `infrastructure/langgraph/`, then is passed into the graph factory during bootstrap. Graph nodes call application services, particularly `application/retrieval/`, instead of embedding Qdrant query logic. ### Infrastructure package `infrastructure/` contains concrete integrations and resource setup. - `postgres/` owns SQLAlchemy engine/sessionmaker setup, ORM models, repository implementations, and transactional outbox persistence. - `qdrant/` owns Qdrant client lifecycle, collection/bootstrap helpers, low-level point operations, and hybrid retrieval adapter mechanics. - `minio/` implements object-storage operations against MinIO/S3-compatible APIs. - `rabbitmq/` owns the RabbitMQ connection/channel lifecycle plus low-level publish and consumer adapters (aio-pika). - `langgraph/` configures the concrete Postgres-backed LangGraph persistence adapters. - `observability/` configures structlog and Langfuse integrations. Infrastructure code receives configuration and is created by a process owner; it must not create mutable external clients at import time. ### Messaging and workers `messaging/` contains versioned event schemas, stable routing-key names, and the outbox-publisher orchestration. The outbox publisher coordinates Postgres outbox records with the RabbitMQ adapter; it does not become a second source of job state. `workers/` contains thin process entrypoints and consumer loops. A worker creates application-lifetime dependencies, consumes a durable RabbitMQ message, binds job logging context, and invokes the corresponding application service. It does not hold parsing/chunking/Qdrant business logic itself. For example: ```text workers/ingestion.py -> application/ingestion/processor.py -> application/points/service.py -> infrastructure/qdrant/points.py ``` ### Bootstrap and resource ownership `bootstrap/` composes configuration and concrete infrastructure adapters for each process entrypoint. FastAPI lifespan owns web-process resources; worker and outbox publisher startup own their corresponding resources. This implements ADR-0012 without turning `app.state` or module globals into an untyped service locator. ## Consequences ### Positive - The repository has clear homes for LangGraph, Qdrant, MinIO, RabbitMQ, Postgres, API, worker, and outbox-publisher code before implementation grows. - HTTP routes, worker consumers, and LangGraph nodes reuse application services while remaining separate transport/execution adapters. - SDK-specific details are isolated, making integration tests and test doubles practical without hiding all code behind unnecessary abstractions. - The layout directly supports ADR-0012's explicit resource ownership and ADR-0014's worker-owned ingestion Chunk/Point CRUD. - A single codebase remains simple to deploy while allowing web, worker, and outbox-publisher processes to scale independently. ### Negative - The initial directory structure is more elaborate than a route-plus-models FastAPI starter application. - Developers must maintain dependency direction rather than importing a concrete client wherever it is convenient. - Some capabilities span several packages by design, for example an upload route, application service, MinIO adapter, outbox repository, and publisher process. - Ports/contracts should remain narrow; excessive abstraction would add ceremony without improving testability or substitutability. ## Alternatives Considered - **Technology-first packages only**: rejected. Directories such as `db`, `qdrant`, `rabbitmq`, and `langgraph` are immediately discoverable, but feature workflows become scattered across every integration package and encourage transport adapters to own business behavior. - **Feature-first packages only**: rejected. Keeping all file, point, and thread code together is attractive, but it obscures ownership of shared external clients and risks duplicating infrastructure integration logic across features. - **Full clean architecture with interfaces for every class/function**: rejected. The application needs clear external boundaries, not abstraction around pure helper functions. Ports are reserved for persistence and external side effects. - **Microservices for ingestion, retrieval, and chat from the start**: rejected. The project needs independent web/worker processes, but a single modular codebase avoids premature network boundaries, deployment complexity, and distributed transaction concerns. - **Put LangGraph under `api/` or workers under `ingestion/` only**: rejected. LangGraph and RabbitMQ workers are independent execution adapters; putting one under another would invert dependencies and make reuse/testing harder.