Files
chatbot_v3/docs/adr/0015-modular-monolith-package-architecture.md
Ali Zarinkolah 88b2db0c3d docs(architecture): replace NATS JetStream with RabbitMQ for job dispatch
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>
2026-08-15 11:25:25 +03:30

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.