Skip to content

Architecture Decision Records

ADRs document the significant architectural decisions made for GrinSystem, along with their context and consequences.

The list below is generated chronologically by the blog plugin. For new ADRs, copy the template into posts/ with a numbered prefix.

ADR-004: SQLAlchemy 2.0 with Async Support

Status

Accepted

Context

We need an ORM for database access in GrinSystem. Requirements: - Async support (FastAPI is async) - Type safety - Good ecosystem support - Migration support - Multi-tenancy RLS compatibility

Decision

We will use SQLAlchemy 2.0 with async support.

Key features used: - AsyncSession for async database operations - select() with 2.0 style queries - Mapped classes with type hints - Alembic for migrations - Connection pool with async support

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine

engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine, class_=AsyncSession)

async with async_session() as session:
    result = await session.execute(select(Recipe).where(Recipe.id == id))
    recipe = result.scalar_one_or_none()

Consequences

Positive

  • Native async/await support
  • Type-safe queries with 2.0 style
  • Mature ecosystem, well documented
  • Alembic integration for migrations
  • Works with PostgreSQL RLS
  • Large community, ongoing development

Negative

  • Learning curve for async patterns
  • More complex than synchronous ORM
  • Connection management requires care
  • Some third-party tools don't support async

Neutral

  • Requires greenlet for some operations

Alternatives Considered

Option Pros Cons Why not chosen
SQLAlchemy 1.4 sync Mature, simple Not async, blocks event loop Performance issue
SQLAlchemy 2.0 async Async, typed, mature Learning curve Chosen
Tortoise ORM Async-native, Django-like Smaller ecosystem, less mature Risk for production
Prisma Type-safe, async Python client less mature Ecosystem risk
Raw SQL Maximum control No ORM benefits, boilerplate Reinventing wheel

References

ADR-003: Inter-Module Communication via In-Process Dispatcher

Status

Accepted

Context

Modules in GrinSystem need to communicate while maintaining loose coupling. Key requirements: - Production order completion triggers warehouse stock updates - Stock changes affect production availability - Batch creation requires production completion event - Future requirement: audit trail of all events - Possible future extraction to microservices

The system is a modular monolith deployed as a single process. While the requirements above are real, they are all satisfiable in-process today: every module runs in the same FastAPI application, so a domain event published by one module can be delivered to another module's handler without a network hop or an external broker. A message broker only becomes necessary once a module is extracted into its own service.

Decision

Modules communicate via domain events delivered through an in-process dispatcher backed by the transactional outbox.

Principles: - No direct module-to-module imports; events are the only cross-module communication, and modules never share repositories. - A command handler writes its events into the outbox table inside the same SQL transaction as the aggregate change (via OutboxEventPublisher / EventPublisherDep) and issues NOTIFY outbox_new. - After commit, the OutboxPoller claims pending rows (FOR UPDATE SKIP LOCKED) and hands each event to the EventDispatcher, which runs every registered @on_event handler concurrently (asyncio.gather), each in its own fresh AsyncSession. - A module subscribes by registering @on_event(EventType) handlers in src/modules/<m>/application/event_handlers.py; load_all_handlers() imports them at startup so the decorators fire. - Events are immutable, versioned, and include tenant_id. - Delivery is at-least-once, so handlers are idempotent (processed_events dedupe).

ZPCompleted (published to outbox)
    └─► OutboxPoller ─► EventDispatcher ─┬─► Warehouse: consume materials
                                         ├─► Traceability: create genealogy link
                                         └─► CRM: advance sales order status

Kafka is deferred until a module is extracted to its own service. At that point the transport — and only the transport — changes: the dispatcher target is swapped for a Kafka producer/consumer, per-module topics (grinsystem.{context}.events) reappear, and the outbox becomes the source for a Kafka relay. The @on_event handler signatures and the published events stay identical, so the extraction is a transport swap, not a domain rewrite.

Consequences

Positive

  • Loose coupling between modules (events only, no shared repositories)
  • Audit trail of all events (the committed outbox rows)
  • Reliable publishing: events commit atomically with the aggregate change, so no event is lost on crash
  • Easy to add new consumers without touching producers
  • Natural fit for eventual consistency
  • No operational broker to run, secure, or monitor while the system is a single deployable
  • Migration path to a broker is mechanical (transport swap) when extraction is actually needed

Negative

  • In-process delivery couples consumer availability to the producing process (acceptable for a monolith)
  • Eventual consistency (not immediate)
  • Debugging event flows still requires tracing across handlers
  • Need for handler idempotency

Neutral

  • Event schema versioning required for evolution
  • A retry/back-off strategy for failed rows is a future milestone (failed rows are currently marked and left for investigation)

Alternatives Considered

Option Pros Cons Why not chosen
Direct imports Simple, synchronous Tight coupling, hard to extract Violates module boundaries
In-process dispatcher + transactional outbox Simple ops, reliable publish, extraction-ready transport swap In-process delivery, no cross-process replay yet Chosen
Apache Kafka now Audit trail, replay, extraction-ready Operational complexity for a single-process app; premature until a module is split out Deferred until module extraction
RabbitMQ Simpler than Kafka Still an external broker the monolith doesn't yet need Same reason as Kafka — premature

Event Flow Example

Manufacturing handler        outbox + OutboxPoller        Warehouse handler
       │                            │                            │
       │  publish ZPCompleted       │                            │
       │  (same txn as aggregate)   │                            │
       │ ──────────────────────────►│                            │
       │         commit + NOTIFY    │   dispatch (fresh session) │
       │                            │ ──────────────────────────►│
       │                            │                            │ Process:
       │                            │                            │ - Deduct stock
       │                            │                            │ - publish StockAdjusted
       │                            │   mark row completed        │

References

ADR-002: Multi-Tenancy via PostgreSQL Row-Level Security

Status

Accepted

Context

GrinSystem is a SaaS application requiring multi-tenancy. Each tenant's data must be isolated from other tenants.

Requirements: - Strong data isolation (no cross-tenant data access) - Simple implementation for developers - Good performance - Auditability

Decision

We will use PostgreSQL Row-Level Security (RLS) for tenant isolation.

Implementation: - Every tenant-scoped table has a tenant_id column - RLS policies automatically filter queries by tenant - Tenant context set via session variable: SET app.current_tenant = :tenant_id - Middleware sets tenant context on each request

ALTER TABLE recipes ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON recipes
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

Consequences

Positive

  • Strong isolation at database level (cannot be bypassed by application bugs)
  • Simplified application code (no manual tenant filtering)
  • Works with ORM (SQLAlchemy)
  • Performance: PostgreSQL optimizes RLS queries

Negative

  • Database-coupled (harder to switch databases)
  • All queries must include tenant_id
  • Requires care with migrations and raw SQL
  • Testing requires proper RLS setup

Neutral

  • DBAs can audit tenant isolation via policy inspection

Alternatives Considered

Option Pros Cons Why not chosen
Separate database per tenant Strongest isolation High ops cost, connection pool exhaustion Overkill for MVP, cost
Separate schema per tenant Good isolation Complex migrations, schema management Added complexity
Application-level filtering Database-agnostic Easy to forget, bugs, inconsistent Security risk
RLS Database-enforced isolation PostgreSQL-specific Chosen

References

ADR-001: Modular Monolith Architecture

Status

Accepted

Context

We need to decide on the architecture style for GrinSystem - a SaaS ERP for food production companies.

Constraints: - Small team (3-7 developers) - 6-month MVP timeline - Need for clear module boundaries - Future possibility of scaling individual modules - Domain complexity (recipes, production, warehouse, traceability)

Decision

We will build GrinSystem as a modular monolith with these characteristics: - Single deployable unit (one FastAPI application) - Clear bounded contexts as modules - Event-driven communication between modules (in-process dispatcher; Kafka deferred until module extraction) - Shared database with logical separation - Each module follows DDD layered architecture

Consequences

Positive

  • Simpler deployment and operations (one service)
  • Faster development iterations
  • Transactions across modules possible
  • Easier debugging and tracing
  • Lower infrastructure cost initially
  • Team can focus on business logic, not distributed systems

Negative

  • All modules scale together (no independent scaling)
  • Deployment requires full system restart
  • Module boundary discipline required
  • Future extraction to microservices requires work

Neutral

  • Module boundaries must be enforced by convention, not infrastructure
  • Team must maintain discipline on module communication

Alternatives Considered

Option Pros Cons Why not chosen
Microservices Independent scaling, deployment Complexity, distributed transactions, ops overhead Team size, timeline, MVP scope
Monolith (no modules) Simplest No boundaries, difficult to maintain, hard to extract later Domain complexity requires boundaries
Modular monolith Balance of simplicity and boundaries Requires discipline Chosen

References