Bowline

Freight operations and workforce management for a 260-person logistics company, from the CEO to the dock crew: one chain of command, one ledger, one audit trail.

Rakibul Hasan Sium · github.com/rhs2

Rust · Axum · SQLxTypeScript · Next.js 15Java 17 · Spring Boot 3Python · FastAPI · scikit-learnGo 1.27PostgreSQL 16 · ltreeRedis · S3 · SESTerraform · AWS ECS FargateGitHub ActionsDocker

In one paragraph

Bowline is the platform a mid-size freight forwarder runs on. It carries the company hierarchy (seven levels, CEO to ground staff), identity and access, HR (leave, shifts, attendance, documents), freight operations (customers, shipments, legs, tracking events, work orders, inventory), double-entry finance (invoices, payments, expenses, vendor bills, payroll, period close, reports), internal mail with company-wide announcements, a support desk with SLAs, and an append-only audit log. It is the 2026 re-engineering of an internal platform first built in 2022 to 2023 for a freight company: the domain carried over, everything else was rebuilt around a Rust core with the heavy or specialised jobs (PDF rendering, spreadsheets, forecasting, mail delivery) in the language that suits them.

Key numbers

6 services

Rust API, Next.js web, Java billing, Python analytics, Go outbox worker and CLI, Terraform infrastructure, each with its own CI job and Docker image.

43 tables

Eight versioned migrations: org and identity, HR, operations, finance, communications, audit and outbox, reference data. 52 permissions in 14 roles.

11 rules

Database integrity rules proven by a rolled-back SQL test in CI: one CEO, no reporting cycles, subtree cascade, balanced ledger, immutable lines, closed periods, leave overlap, append-only audit.

260 people

A seeded company with a full reporting tree, so every permission scope, approval chain and messaging rule can be exercised from real logins.

What makes it more than a CRUD app

ConcernHow Bowline handles it
Chain of commandEach employee's reporting path is a PostgreSQL ltree maintained by triggers. Re-parenting a manager rewrites the whole subtree in one statement; cycles and a second CEO are rejected. "Everyone below me" is a GiST index lookup, and every scoped permission (:self, :subtree, :department, :all) is evaluated against the same path.
MoneyDouble-entry ledger. A deferred constraint trigger refuses to commit an unbalanced entry, journal lines are immutable (corrections are reversing entries), and posting into a closed fiscal period fails at the database. Invoices, payments, expenses, vendor bills and payroll are all just sources of journal entries.
Who can message whomA rule table, enforced in the API and reflected in the recipient picker: support desk, manager and reports, same department for everyone; the subtree for managers; the whole company for executives.
ApprovalsLeave goes to the direct manager; expenses need manager then finance; invoices above a threshold need finance approval before issue; payroll needs the CFO; closing a period locks it.
AuthenticationArgon2id passwords, 15-minute access tokens, rotating refresh tokens with family reuse detection, lockout after failed logins, forced password change on first login. The web app keeps tokens in httpOnly cookies behind a backend-for-frontend proxy.
Email deliveryTransactional outbox: every message writes rows in the same transaction; a Go worker claims them with FOR UPDATE SKIP LOCKED, retries with exponential backoff, and parks failures after eight attempts.
AttributionEvery mutation writes actor, request id and a before/after snapshot to an append-only audit log, queryable by executives and auditors.
OperationsHealth and readiness probes, Prometheus metrics, structured logs with request ids, one command for local development, one pipeline to production (OIDC to AWS, ECR, Terraform apply behind a protected environment, migrations as a one-off task).

Architecture

                       +------------------+
   browser  ---------> |  web  (Next.js)  |  BFF: httpOnly cookies, /api/proxy
                       +--------+---------+
                                | HTTPS /api/v1  (JWT bearer)
                                v
   bowctl (Go) ------> +------------------+      +--------------------+
                       |  api  (Rust)     |----->| billing  (Java)    |  invoice PDFs,
                       |  Axum + SQLx     |      | Spring Boot        |  statements, AR aging xlsx
                       |  identity, org,  |      +--------------------+
                       |  hr, ops,        |      +--------------------+
                       |  finance, comms, |----->| analytics (Python) |  delay risk,
                       |  audit           |      | FastAPI + sklearn  |  volume forecast
                       +---+--------+-----+      +--------------------+
                           |        |
                 PostgreSQL 16      S3 (documents, PDFs)
                 (ltree, citext)
                           ^
                           |  outbox table, SKIP LOCKED
                       +---+--------------+
                       | notify (Go)      |  SES in production, Mailpit locally
                       +------------------+

The API is the only writer of business data. Billing and analytics read through a read-only database role and are called by the API over HTTP; the notify worker may touch only the outbox table.

Verification

Every number here came from running the system, on PostgreSQL 16 with all six services up. None of it is an estimate.

266

Automated tests across five languages: 66 TypeScript, 62 Rust (27 unit and 35 integration over the real HTTP surface), 58 Go, 45 Java, 35 Python.

33 of 33

Steps of the end-to-end scenario, which drives seven seeded roles through one working day and checks the rules rather than the plumbing.

126

API operations across 99 OpenAPI paths, with clippy --all-targets -- -D warnings clean.

0.00

The trial balance after seeding 260 employees, 300 shipments, 90 invoices and 180 journal entries, and again after the scenario issues and collects another invoice.

The scenario is the interesting part, because each step is a rule that could plausibly be got wrong:

StepWhat it proves
A dock worker announces to the companyRefused. Only messages:broadcast:company holders may, and the CEO's announcement still reaches that worker's inbox.
A dock worker opens a thread with the CFORefused, while a thread with their own manager succeeds. The messaging rules are enforced server side, not just hidden in the recipient picker.
A shipment jumps straight to delivered409 invalid_transition, while draft to booked to picked_up is accepted.
Someone else moves a driver's work orderRefused. Only the assignee or a manager above them may.
An invoice is overpaid422. The correct payment posts, and the trial balance still sums to zero.
An unbalanced journal entry is posted422, surfaced cleanly rather than exploding at commit, because the deferred constraint is checked for one round trip inside the transaction.
An entry is posted into a closed periodRefused at the database, not merely in the API.
A dock worker reads the ledger or the audit trail403 for both, while the CEO reads the audit rows for the invoice just issued.

Three bugs found by booting the service that no unit test would have caught: two Prometheus recorders fighting over a port, a stack overflow building a 99 route tower stack, and an OpenAPI schema that recursed forever on the self referencing org chart. One authorisation leak found by the integration suite: GET /employees/{id}/reports accepted org:read, which every user holds, so any employee could read anyone's reports along with their contact details.

Run it

git clone https://github.com/rhs2/bowline && cd bowline
cp .env.example .env
make up && make migrate && make seed
make api      # http://localhost:8080, Swagger UI at /docs
make web      # http://localhost:3000, sign in as ceo@bowline.example / Bowline!2026
make test     # every suite;  make smoke  walks the end-to-end scenario