---
title: Maritime supply chain platform
slug: maritime-supply-platform
author: Charis Nikolaidis
year: 2025 to now
type: Enterprise system
tech: [FastAPI, PostgreSQL, Redis, Celery, React, Azure, Bicep]
url: https://charisnikolaidis.com/projects/maritime-supply-platform/
---

# Maritime supply chain platform

A maritime CRM and ERP for a ship-supply operation, built from an empty repo and
owned end to end. FastAPI, PostgreSQL, Redis, React, and a Celery pipeline on AIS
positions from 20,000+ vessels. Procurement is one aggregate because a vessel
call is one negotiation, not five documents that happen to reference each other.

**Type:** Enterprise system · **Period:** 2025 to now
**Stack:** FastAPI, PostgreSQL, Redis, Celery, React, Azure, Bicep

The client and the product are not named, and there are no links. The system is
described by its decisions instead, which is the part worth reading anyway.

## The problem

- A real ERP and CRM for ship supply: vessels, ports, clients, suppliers, items,
  the chain from a vessel call to an invoice, and underneath all of it a live AIS
  feed of vessel traffic worldwide.
- A system this size rots predictably: transactions half-commit, relationships
  lazy-load after the request is gone, one document saves while its siblings
  don't, a cache serves data a rolled-back write never made real.
- Built from an empty repo, owned end to end. The aim was to make those failure
  modes structurally impossible, not a thing you remember to avoid.

## Key decisions and tradeoffs

- **A unit of work owns every transaction; repositories never commit.**
  SQLAlchemy errors are translated at the boundary (`StaleDataError` → conflict,
  unique violation → duplicate). Reads retry with backoff; writes never, since
  they aren't idempotent.
- **ORM relationships are `lazy='raise'`.** Each read declares its eager-load
  graph by hand, and three test tiers prove a DTO still serialises after the
  session closes, so the N+1 / `MissingGreenlet` class of bug fails in CI rather
  than in production.
- **Procurement is one Job aggregate**, a vessel call owning its inquiries,
  quotations, orders, invoices and payments. Workflow endpoints only prepare
  DTOs; the client sends one PATCH and an eight-step validator checks quantities,
  totals, invoice cumulation, allocations and FX before a single commit.
  Per-document REST endpoints, the obvious alternative, cannot hold the
  cross-document invariants.
- **Split Celery queues** keep a slow AIS backfill from starving maintenance. The
  task source of truth is a Postgres ledger, not the Celery result backend, so
  history is queryable and survives eviction.
- **List caches invalidate by generation bump in a post-commit hook**, never
  before the write lands.

## What's inside

- Modular distributed system with enforced import boundaries: routes → services
  → repositories → models.
- A unit of work owns every transaction; repositories never commit; SQLAlchemy
  errors become domain errors at the boundary.
- A standardised testing approach with three test tiers, totalling 4,282 tests
  across 288 files, catching issues in CI before they reach production.
- Procurement as one Job aggregate: inquiry → quotation → order → invoice →
  payment, validated and committed atomically in a single PATCH.
- Celery AIS pipeline on dedicated queues; a Postgres task ledger replaces the
  result backend as source of truth.
- Redis-first cache; list invalidation via O(1) generation bump in a post-commit
  hook.
- Defence in depth: short-lived JWT in HttpOnly cookies with refresh tokens,
  CSRF, IP and session-to-device binding, fail-closed RBAC plus policies.
- Field-level audit and admin forensics: "who changed what" and "why did this
  vessel fail to sync" are both answerable.
- The commercial reality modelled properly: port-wise item pricing,
  multi-currency with exchange rate history, unit-of-measure conversion between
  purchase and consumption units, delivery and payment terms per supplier and
  per port.

## Infrastructure

- The entire Azure environment as Bicep modules: container apps, Postgres
  flexible server, Redis Enterprise, container registry, private endpoints, DNS,
  monitoring.
- **No public database.** Postgres and Redis are reachable only over private
  endpoints inside the VNet, with egress pinned behind a NAT gateway so partners
  can allow-list one address.
- **Nothing authenticates with a stored secret.** Container apps pull from the
  registry with managed identity, and GitHub Actions signs in to Azure by
  workload identity federation.
- The pipeline lints, runs static analysis, validates the Bicep, builds, deploys,
  and has an explicit rollback job for backend and frontend both.
- Migrations run as Container Apps Jobs rather than inside a booting app, so a
  failed migration never takes the service down with it.
- Log Analytics and Application Insights, with a custom Azure Workbook checked
  into the repo alongside the infrastructure.

## Scale and failure modes

- 4,282 tests across 288 files (unit, integration, API, concurrency,
  read-contract), with per-worker database clones for parallel runs.
- Eight-layer middleware: CORS, session binding, CSRF, rate limiting, audit
  context, security headers, unified response envelope, exception handling.
- Field-level audit and admin forensics capture provider request/response pairs,
  errors-only and on their own transaction, so a failed fleet sync is explainable
  after the fact.
- AIS positions land in an append-only raw table with BRIN indexes on time and
  GIN trigram indexes for search, because the write path is chronological and the
  read path very much is not.
- Celery dispatch is guarded in Redis so a retried enqueue does not run the same
  sync twice.
- Deploys pinned to image digests with expand-and-contract migrations, since
  there is no automatic schema rollback.

## Related

- Profile: <https://charisnikolaidis.com/llms.txt>
- All projects: <https://charisnikolaidis.com/projects/>
- Queryable over MCP: `https://charisnikolaidis.com/mcp`
