Switch the durable ingestion/maintenance job-dispatch broker decision from NATS JetStream to RabbitMQ (aio-pika), rewriting ADR-0014 and propagating the terminology change through ADR-0015, ADR-0016, and the ingestion vertical-slice plan. Adds aio-pika as a runtime dependency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
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:
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:
alembic.ini
alembic/
├── env.py
└── versions/
Keep tests outside the application package and organize them by testing boundary:
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.
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 asAuthContextandAsyncSession;schemas/contains public Pydantic request/response/error models;router.pycomposes 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.pybuilds and compiles the graph from explicitly passed dependencies;state.pydefines 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.pycontains 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:
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, andlanggraphare 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 underingestion/only: rejected. LangGraph and RabbitMQ workers are independent execution adapters; putting one under another would invert dependencies and make reuse/testing harder.