Files
chatbot_v3/docs/adr/0015-modular-monolith-package-architecture.md
Ali Zarinkolah fd70ad01af docs(architecture): adopt inline synchronous ingestion (ADR-0017)
Why:
- ingestion is inline in the request instead of dispatched through RabbitMQ/outbox/worker; ADR-0014 is superseded (not deleted) and named as the design to adopt once ingestion needs to move off the request path.

Changes:
- new ADR-0017 plus amendments to every ADR/plan that referenced the job-shaped/broker contract, so none silently contradict it.

Impact:
- no broker, outbox, or worker code; rabbitmq test marker removed.
2026-08-16 11:53:59 +03:30

12 KiB

0015. Modular monolith package architecture

Status

Proposed

Amended by ADR-0017: there is no broker, outbox publisher, queue, or separate worker process for now. messaging/, workers/, and infrastructure/rabbitmq/ are part of the target shape but are not created yet; ingestion runs inline in the request, so the FastAPI route is the only entry adapter and it calls application/ingestion/ directly. Every layering rule below applies unchanged.

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         # ADR-0017: not yet; no outbox today
│   ├── qdrant/
│   ├── minio/
│   ├── embedding/            # async embedding adapters (batched, bounded)
│   ├── rabbitmq/             # ADR-0017: not yet; ADR-0014 target only
│   ├── langgraph/
│   └── observability/
├── messaging/                # ADR-0017: not yet; ADR-0014 target only
│   ├── events.py
│   ├── subjects.py
│   └── outbox_publisher.py
└── workers/                  # ADR-0017: not yet; ADR-0014 target only
    ├── 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/
│   └── 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 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.
  • embedding/ implements the async dense/sparse embedding adapters, including provider batching and the concurrency semaphore from ADR-0017.
  • rabbitmq/ owns the RabbitMQ connection/channel lifecycle plus low-level publish and consumer adapters (aio-pika). Not created while ADR-0017 stands.
  • 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

Under ADR-0017 neither package exists yet: ingestion runs inline in the request, so the FastAPI route plays the entry-adapter role described here and obeys the same rule — it binds logging context and invokes an application service, and holds no parsing/chunking/Qdrant business logic itself. The rest of this section describes the shape both packages take when ADR-0014 is adopted.

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, 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.