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>
278 lines
11 KiB
Markdown
278 lines
11 KiB
Markdown
# 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.
|