Skip to content

High-Level Design (HLD)

System Overview

GrinSystem is a SaaS ERP for food production companies, built as a modular monolith with event-driven communication between modules.

Architecture Style

Modular Monolith with Domain-Driven Design (DDD)

Why Modular Monolith?

Aspect Decision Rationale
Deployment Single deployable unit Simpler ops for small team (3-7 people)
Module isolation Strict bounded contexts Future extraction to microservices possible
Communication Event-driven (in-process dispatcher) Loose coupling within one process; Kafka deferred until a module is extracted
Database Single PostgreSQL Transactions across modules, simpler management

High-Level Architecture

                                    ┌─────────────────┐
                                    │   Client Apps   │
                                    │  (Web/Tablet)   │
                                    └────────┬────────┘
┌────────────────────────────────────────────────────────────────────┐
│                         API Gateway                                 │
│                    (FastAPI Application)                            │
│  - Authentication (JWT)                                             │
│  - Tenant Context Middleware                                        │
│  - Request Routing                                                  │
│                                                                    │
│   ┌─────┬─────┬─────┬─────┬─────┬─────┬────┐                        │
│   ▼     ▼     ▼     ▼     ▼     ▼     ▼                             │
│  ┌──────────────────────────────────────────────────┐             │
│  │  identity │ item_catalog │ manufacturing          │             │
│  │  warehouse │ traceability │ crm │ invoicing        │             │
│  │                                                    │             │
│  │  Each module: domain/ application/ infra/ api/    │             │
│  └──────────────────────────────────────────────────┘             │
│         │ publish (outbox row)        ▲ @on_event handler          │
│         ▼                             │                            │
│  ┌──────────────────────────────────────────────────┐             │
│  │   In-Process Event Dispatcher                     │             │
│  │   OutboxPoller → EventDispatcher → @on_event      │             │
│  └──────────────────────────────────────────────────┘             │
└────────────────────────────┬───────────────────────────────────────┘
                    ┌────────▼────────┐
                    │   PostgreSQL    │
                    │   (RLS enabled) │
                    │   + outbox tbl  │
                    └─────────────────┘

The event dispatcher lives inside the FastAPI process — there is no broker. Cross-module coupling is one-directional through @on_event handlers on published events. When a module is later extracted to its own service, the dispatcher target is swapped for a Kafka producer/consumer and handler signatures stay identical.

Module Structure

Every module follows the same 4-layer skeleton with the same sub-folder structure regardless of size. Even single-entity modules use folder-per-layer-piece (entities/, models/, routers/, schemas/) so cross-module reading and refactoring stay frictionless.

src/modules/{module}/
├── domain/              # Pure business logic — zero framework imports
│   ├── entities/        # one file per entity / aggregate root
│   ├── value_objects/   # one file per VO; enums also live here
│   ├── repositories/    # ABCs only (one file per aggregate)
│   ├── ports/           # outbound interfaces this module needs from others
│   ├── services/        # cross-aggregate domain services
│   ├── events.py        # DomainEvent subclasses (PascalCase, past tense)
│   └── exceptions.py
├── application/         # CQS orchestration
│   ├── commands/        # one file per command (CreateXCommand + Handler)
│   ├── queries/         # one file per query
│   └── dtos.py          # shared result / read-model dataclasses
├── infrastructure/      # technical implementations
│   ├── models/          # one SQLAlchemy model per file; __init__ re-exports for Alembic
│   ├── repositories/    # one SQLAlchemy repo impl per file
│   └── event_publisher.py
└── api/                 # HTTP interface
    ├── router.py        # composes routers/ into a single <module>_router
    ├── routers/         # one APIRouter per entity / use-case
    ├── dependencies.py  # Annotated dependency aliases for this module
    └── schemas/         # one file per use-case + shared.py

Router & Model Composition Across Modules

Two pieces of shared plumbing connect the modules so module code never edits the application bootstrap:

  • src/shared/api/router.py declares api_router = APIRouter(prefix="/api/v1") and includes every module's <module>_router. main.py mounts only health_router + api_router.
  • src/shared/db/registry.py imports every module's infrastructure/models/__init__.py so all models land on Base.metadata. Alembic autogenerate then sees the full schema without per-module wiring duplication.

Shared SQLAlchemy Mixins

All aggregate-root models compose mixins from src/shared/db/mixins.py: UUIDPkMixin, TenantScopedMixin, TimestampMixin, AuditedByMixin. OptimisticLockMixin is default-on for editable aggregates — it adds a version column AND wires SQLAlchemy's version_id_col so every UPDATE bumps the version and raises StaleDataError on lost races. Per-model column definitions cover only the domain-specific fields.

Reliable Event Publishing

Cross-module events are published via a transactional outbox, not directly from command handlers. Handlers write to an outbox table in the same SQL transaction as the aggregate change; the in-process OutboxPoller then claims committed rows and hands each event to the EventDispatcher, which invokes every @on_event handler registered for that event type — each handler in its own session. See conventions.md § Transactional Outbox.

Idempotency

Two layers, both mandatory: - Write-API idempotency — clients pass Idempotency-Key header; server caches (tenant_id, route, key) → response for 24 h. - Handler-side idempotency — dispatch is at-least-once, so every event handler dedupes against a processed_events table within the same transaction as its side effects.

See conventions.md § Idempotency Keys.

ID Generation

Every aggregate ID is UUIDv7 (time-ordered) generated via src.shared.ids.new_id(). UUIDv4 is forbidden — index fragmentation under sustained inserts is a measurable performance loss.

Data Flow

Write Path (Command)

HTTP Request
API Route (FastAPI)         ← maps request schema → Command dataclass
Command Handler (Application) ← load → validate → call domain → save → emit → return
    ├──► Domain Aggregate (Domain)
    │         └──► Domain Event
    ├──► Repository (Infrastructure) → PostgreSQL (with RLS)
    └──► Event Publisher (Infrastructure) → outbox row (same transaction)
              └──► OutboxPoller → EventDispatcher → @on_event handlers (post-commit)
Response (API schema)       ← maps CommandResult → response schema

"validate" in this pipeline = actor-aware authorization + cross-aggregate / cross-module guards only. Pure state-machine invariants (e.g. "cannot deactivate an already-INACTIVE item") live in the domain aggregate and are not duplicated in the handler. The one exception is an error-precedence short-circuit before expensive port calls — see conventions.md § Guard Placement.

Read Path (Query)

HTTP Request
API Route (FastAPI)         ← maps request params → Query dataclass
Query Handler (Application) ← load from repo → map to read model → return
    └──► Repository (Infrastructure) → PostgreSQL (with RLS)
Response (API schema)       ← maps ReadModel → response schema

CQS rule: Commands change state and have side effects (events, DB writes). Queries only read — no state changes, no events emitted.

Multi-Tenancy

PostgreSQL Row-Level Security (RLS) ensures tenant isolation:

-- Applied to all tenant-scoped tables
ALTER TABLE recipes ENABLE ROW LEVEL SECURITY;

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

Middleware sets tenant context per request:

async def set_rls_policy(session: AsyncSession, tenant_id: UUID):
    await session.execute(
        text("SET app.current_tenant = :tenant_id"),
        {"tenant_id": str(tenant_id)}
    )

Event-Driven Communication

Event Routing

There are no topics. A published event is matched to handlers by its type: each handler registers for a concrete DomainEvent subclass via @on_event(EventType), and the EventDispatcher invokes every handler registered for type(event). Handler modules live at src/modules/<module>/application/event_handlers.py and are auto-discovered at startup. See conventions.md § Event Handlers (@on_event).

Forward-migration note. Topics return only when a module is extracted to its own service: the dispatcher target is swapped for a Kafka producer (publish side) / consumer (subscribe side), at which point per-module topics such as grinsystem.<context>.events are reintroduced. The @on_event handler signatures and the event payloads do not change.

Event Envelope

{
  "event_id": "uuid",
  "event_type": "RecipeCreated",
  "aggregate_id": "string",
  "aggregate_type": "Recipe",
  "tenant_id": "uuid",
  "timestamp": "2026-03-30T10:00:00Z",
  "version": 1,
  "payload": { ... }
}

Key Event Flows

Production Order Completion:

Manufacturing: ZPCompleted
    ├──► Warehouse: finalise actual consumption, create output LOT
    └──► Traceability: record input→output LOT genealogy links

Goods Receipt (PZ approved):

Warehouse: StockReceived
    └──► Traceability: record LOT entry event

Sales Order Confirmed:

CRM: SalesOrderConfirmed
    ├──► Warehouse: reserve available FG stock
    └──► Manufacturing: create draft ZP for shortfall (ZPRequested)

Recall Initiated:

Traceability: RecallInitiated
    └──► CRM: surface recall flag on affected customer orders (Phase 2)

Modules

Module Phase Responsibility
identity 1 Auth, tenants, users, roles, RLS context
item_catalog 1 Item master data — single source of truth for item identity
manufacturing 1 Recipes, BOM, production orders, costs, allergens, nutrition
warehouse 1 Stock ledger, LOTs, FEFO, PZ/WZ, suppliers
traceability 1 Batch genealogy, forward/backward trace queries, recalls
crm 2 Customers, sales orders, pricing, API, webhooks
invoicing 2 Invoices, credit notes, payment tracking; KSeF in Phase 4

Technology Decisions

See docs/adr/ for detailed Architecture Decision Records.

Decision Choice ADR
Architecture style Modular monolith ADR-001
Multi-tenancy PostgreSQL RLS ADR-002
Inter-module communication In-process dispatcher ADR-003
ORM SQLAlchemy 2.0 (async) ADR-004

Security

  • Authentication: JWT tokens
  • Authorization: Permission-based (capabilities mapped to roles in PERMISSION_ROLES). Canonical roles: Admin, Manager, Technologist, Operator, Warehouse Operator, Sales, Accountant, Viewer. See docs/architecture/conventions.md § Authorization.
  • Tenant isolation: PostgreSQL RLS
  • API security: HTTPS, rate limiting, input validation
  • Audit: All domain events recorded via the transactional outbox

Scalability

  • Horizontal: Stateless API layer, can scale behind load balancer
  • Vertical: Database scaling as needed
  • Future: Module extraction to microservices if required

Observability

OpenTelemetry for logs, metrics, and traces. Application exports OTLP to a local OTel Collector which forwards to SigNoz (self-hosted, same stack in dev / qa / prod). Auto-instrumentation covers FastAPI, SQLAlchemy/asyncpg, Redis, and httpx; the in-process outbox dispatcher emits its own spans and metrics; structlog ships application logs to the same Collector and correlates them with traces via trace_id. Full reference: Observability.

Deployment

┌─────────────────────────────────────┐
│         Docker Compose              │
│  ┌───────────┐  ┌───────────────┐  │
│  │  API      │  │  PostgreSQL   │  │
│  │  :8000    │  │  :5432        │  │
│  └───────────┘  └───────────────┘  │
│                 ┌───────────────┐  │
│                 │   Redis       │  │
│                 │   :6379       │  │
│                 └───────────────┘  │
└─────────────────────────────────────┘

The event dispatcher runs in-process inside the API container — there is no broker service to deploy. A Kafka service reappears only when a module is extracted to its own deployable unit.