Section 03
System Architecture
One monorepo, one API, one worker image running ~70 connector plugins. Deliberately boring.
Container view
flowchart TB
subgraph clients[Clients]
FL[Flutter app
iOS and Android]
WEB[Next.js web
app and admin]
end
subgraph edge[Edge]
CF[Cloudflare
DNS, WAF, CDN]
end
subgraph core[Application]
API[FastAPI
REST plus OpenAPI]
SCHED[Scheduler
poll planner]
WORK[Connector workers
70 plugins, one image]
AI[AI service
order summarisation]
NOTIF[Notification dispatcher]
end
subgraph data[Data]
PG[(PostgreSQL
primary plus replica)]
RD[(Redis
queue and cache)]
OBJ[(Cloudflare R2
PDFs and orders)]
end
subgraph ext[External]
COURTS[~70 court sites]
WA[Interakt WhatsApp]
FCM[FCM push]
MAIL[Email provider]
PAY[Razorpay]
end
FL --> CF
WEB --> CF
CF --> API
API --> PG
API --> RD
API --> OBJ
API --> PAY
SCHED --> RD
RD --> WORK
WORK --> COURTS
WORK --> PG
WORK --> OBJ
WORK --> AI
AI --> PG
PG --> NOTIF
NOTIF --> WA
NOTIF --> FCM
NOTIF --> MAIL
Fig 3.1 — Production container diagram
Why one worker image and not 70 services
The old system runs ~70 Flask apps, each with its own repository, credentials, dependency tree, and pm2 process. They share a data model, a database, a notification path, and roughly 90% of their code. What actually differs per court is the URL, the form fields, the parse rules, and the captcha strategy — configuration and a parser class, perhaps 200 lines.
That split pays every cost of microservices and earns none of the benefits: independent scaling is unused, team autonomy is irrelevant at this headcount, and blast-radius isolation is defeated anyway by the shared database and shared credentials.
| Dimension | Today | Proposed |
|---|---|---|
| Repositories | 78 | 1 |
| Deploy targets | ~70 pm2 processes | 1 image, N replicas |
| Credential sets | 49 | 1 secret store |
| Python runtimes | 6 versions | 1 (3.12) |
| Helper copies | 50 | 1 package |
| Fixing a shared bug | up to 50 edits | 1 edit |
| Adding a court | new repo + deploy | 1 parser class + config |
Stated honestly, what this costs
- Shared blast radius. A bad deploy affects all courts at once. Mitigated by staged rollout and per-connector circuit breakers — but this is a real regression from today's accidental isolation.
- Noisy neighbours. One slow court site can starve the worker pool. Per-court concurrency limits are required from day one, not added later.
- Merge contention grows with team size. Not a concern below roughly eight engineers.
Repository strategy
Two repositories, not one and not seventy-eight. The split follows the one genuine seam in this system: the app store.
| Repository | Contents | Cadence | Toolchain |
|---|---|---|---|
clerkify | API, worker, connectors, AI, web, core, model training, infra, docs | Deploy on merge, many times a day | Python + Node |
clerkify-mobile | Flutter app | Release every 1–2 weeks, gated by store review | Dart + Xcode + Gradle |
Why mobile is separate
- The release gate is different in kind. Backend ships when CI is green. Mobile ships when Apple and Google say so, then rolls out in phases, and users stay on old versions for months. Those two rhythms in one repository means one is always waiting on the other.
- The toolchain is heavy and unrelated. Xcode, Gradle, provisioning profiles, signing certificates, fastlane. A backend engineer cloning the platform repo should not inherit 500 MB of iOS build configuration they will never open.
- Mobile CI is slow. Simulator builds and store uploads measured in tens of minutes, against a backend suite that should finish in under five.
- Versioning is genuinely independent. The API must support the oldest app version still in the wild — a constraint that is clearer when the two are separate artifacts with an explicit contract between them.
Why web is not separate
Next.js deploys on exactly the same trigger as the API, has no store gate, and benefits from atomic changes across the API and its consumer. Keeping it in the platform repo means an endpoint and the screen that uses it change in one commit and one review. The Node toolchain sitting beside Python is a minor cost, contained by path-filtered CI.
Repository layout
clerkify/ ← platform monorepo
├── apps/
│ ├── api/ FastAPI — REST, auth, OpenAPI spec
│ ├── worker/ connector runtime + scheduler
│ ├── ai/ order summarisation, LangGraph
│ └── web/ Next.js — user web app + admin
├── packages/
│ ├── core/ domain models, db, config (replaces 50 copies)
│ ├── connectors/ ~70 plugins, one file each
│ └── clients/ generated API clients (TS + Dart)
├── ml/ captcha model training — separate dep group,
│ heavy deps, never in the worker image
├── infra/
│ ├── compose/ local + staging
│ └── terraform/ production
└── docs/ this documentation site
clerkify-mobile/ ← Flutter app
├── lib/
├── ios/ android/
└── pubspec.yaml depends on the published Dart client
Adding a court becomes a pull request touching two files — a parser in packages/connectors/ and a row in the court registry. Not a repository, a deploy target, and a duplicated .env.
A monorepo is not a monolith
One repository does not mean one deployable. clerkify builds four independent images — API, worker, AI service, web — each scaled and deployed separately. Path-filtered CI means editing a connector rebuilds the worker and nothing else.
What the single repository buys is a shared source of truth: one version of the domain model, one set of helpers, one CI configuration, and atomic commits across a contract and its consumers. What it does not buy — and must not be allowed to create — is a single lock-step deploy.
How the two repositories stay in contract
The API is the boundary, and it is not hand-maintained on either side. clerkify CI generates an OpenAPI spec on every merge and publishes typed clients from it — a TypeScript package consumed by apps/web in-repo, and a Dart package consumed by clerkify-mobile. Client drift becomes impossible rather than merely discouraged.
Because mobile versions live in the wild for months, the API is additive-only within a major version: fields may be added, never removed or repurposed. Breaking changes go behind a new version prefix with the old one supported until telemetry shows the affected app versions have drained.
When to split further
Named now so a future split is a decision rather than a drift:
- Beyond roughly eight engineers, when merge contention becomes real rather than theoretical.
- If the AI service acquires GPU infrastructure and a genuinely independent release cycle.
- If a court connector ever needs isolation for legal reasons — an unlikely but conceivable requirement.
None of these apply at current headcount. Splitting before they do would reproduce the coordination cost that made 78 repositories unmanageable.
What happens to the old repositories
Archive, do not delete. The 78 repos hold years of hard-won knowledge about how each court site behaves — the moat described in Overview. Make them read-only, keep them cloneable for reference during the M6 migration, and retire them only once every court has a green canary on the new framework. They stop being deploy targets on day one; they stop being useful much later.
Request path vs poll path
The two paths have very different characteristics and should be reasoned about separately.
| Request path | Poll path | |
|---|---|---|
| Trigger | User opens app | Scheduler, daily |
| Latency budget | < 300 ms | minutes is fine |
| Volume | Low, spiky, business hours | High, steady, overnight |
| Failure impact | Visible, user retries | Silent — the real danger |
| Scaling axis | Concurrency | Cost per poll |
Poll work never runs inside a request. The API enqueues and returns; workers do the slow work. The one exception is an explicit user-triggered "refresh this case now", which is queued at high priority and surfaced as a pending state in the UI.
Capacity planning is unusually easy here
Poll load is known in advance — it is a function of tracked matters, running on a schedule you control, in a window you choose. There is no thundering herd and no unpredictable burst. This is why the infrastructure in Environments can stay small and static rather than elastic, and it is a large part of the argument against Kubernetes in Technology Decisions.