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_eventhandlers 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.pydeclaresapi_router = APIRouter(prefix="/api/v1")and includes every module's<module>_router.main.pymounts onlyhealth_router+api_router.src/shared/db/registry.pyimports every module'sinfrastructure/models/__init__.pyso all models land onBase.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>.eventsare reintroduced. The@on_eventhandler 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):
Sales Order Confirmed:
CRM: SalesOrderConfirmed
├──► Warehouse: reserve available FG stock
└──► Manufacturing: create draft ZP for shortfall (ZPRequested)
Recall Initiated:
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. Seedocs/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.
Related Documents¶
- Bounded Contexts - Module boundaries and responsibilities
- Conventions - Coding standards and patterns
- Observability - Logs, metrics, traces with OpenTelemetry + SigNoz
- ADR-001 - Architecture style decision