← All work

Event-driven API

Job Hunt CRM: an event-driven application tracker

An event-driven CRM whose core is a guarded state machine over an append-only event log. Legal-edge-only transitions, idempotent webhook ingestion, and a pluggable AI service with a deterministic offline fallback.

Role
Author, open-source portfolio project
Period
2025
Stack
NestJS · TypeScript · TypeORM · PostgreSQL / SQLite · EventEmitter2 · @nestjs/schedule

This one is open source, so you can read the whole thing. It is a job-application tracker, built like a production system to show the reliability patterns I use in my paid fintech work.

Most job trackers are CRUD over a spreadsheet. I built this one around the application lifecycle instead of the storage. It uses the same reliability patterns as my fintech work: guarded transitions, an immutable event log, and exactly-once ingestion.

The problem

An Application is not a row you edit. It is an entity with a lifecycle. It can only move along legal edges, so you cannot jump SAVED → OFFER. Terminal outcomes have to lock. The funnel has to stay honest even when an application gets rejected after reaching a late stage. And inbound signals, like a recruiter reply or a calendar confirmation, can show up more than once. As plain CRUD, each of those becomes a bug.

Architecture

The write path is the Applications module. It is the aggregate root, guarded by a finite state machine. Every accepted transition appends an immutable event to the log, in the same database transaction as the status update, and then emits a domain event onto an in-process bus. History is never computed from the mutable status field. The append-only log is the source of truth. Two consumers subscribe to the bus without ever touching the write path: a cron reminder worker and a derived analytics read model. Inbound webhooks have to clear an idempotency ledger before they can cause any effect. The tailor service is pluggable, using the model when a key is set and a deterministic heuristic when it is not.

Decisions and tradeoffs

A declarative guarded state machine, not scattered if checks

Every legal transition lives in one adjacency table, behind a canTransition(from, to) guard. Terminal states (ACCEPTED, REJECTED, WITHDRAWN) have no outgoing edges at all. An illegal move comes back as an HTTP 409 that quotes the machine’s own reasoning (Illegal transition SAVED -> OFFER. Allowed: APPLIED, WITHDRAWN). It never silently does nothing. And because that same table also defines the funnel ordering, the guard and the analytics cannot drift apart.

Rejected: status as a free string with validation sprinkled across controllers. It drifts, and “which transitions are legal?” stops having a single answer.

Analytics derived from the event log, not counters

The funnel is derived from the STATUS_CHANGED event stream rather than a mutable counter. That is what lets a candidate who reached INTERVIEW and was then rejected still count toward interview conversion. The history is a sequence of things that happened, not a counter you reset. Events are insert-only. A @BeforeInsert hook stamps an ISO-8601 UTC timestamp once, and nothing ever updates it.

Rejected: per-stage counters on the application row. Fast to read. But they erase the history the moment a status changes, and they quietly lie after any out-of-order update.

Idempotent webhooks via a unique (provider, dedupeKey) ledger

Inbound events are deduped by claiming a receipt first: an insert into a table with a unique (provider, dedupeKey) constraint. A redelivered event trips the constraint, gets caught, and comes back as { status: 'duplicate' }. Nothing is processed twice. The check behaves the same on both drivers (Postgres 23505, SQLite SQLITE_CONSTRAINT), so correctness does not hang on which database is running.

Rejected: “look it up, then insert if it is missing.” Under concurrent delivery there is a race between the read and the write. The database’s unique constraint is the only referee that actually settles it. This is the same exactly-once pattern I ran on production payment callbacks; the Vida case study has the details.

Event-driven decoupling so new consumers never touch the write path

Modules publish domain events over EventEmitter2 instead of calling each other directly. The reminder worker, the analytics read model, and any notifier I add later all subscribe to the same application.* stream. Adding an email or Slack consumer is just a new subscriber. The Applications module never has to know it exists.

Tradeoff: in-process events do not survive a crash. At this scale that is an acceptable tradeoff. The upgrade path is in the roadmap: an outbox for guaranteed delivery and BullMQ/Redis for the cron scan. The worker’s scan was written so that port stays clean.

A pluggable AI service with a deterministic offline fallback

Tailoring takes a job description and the stored resume and produces CV bullets and a cover letter. With ANTHROPIC_API_KEY set, it calls the model. The resume goes in a cached system prefix (cache_control: ephemeral), so repeat tailorings reuse it at roughly 10x cheaper input while the job description varies per request. With no key set, it drops to a deterministic keyword-overlap heuristic behind the exact same interface. The endpoint always works, offline and in CI, and the tests never reach for the network.

Rejected: making the LLM a hard dependency. That leaves the feature untestable without a key, flaky in CI, and dead offline.

One model call, on tailor only

Tracking a posting is free. Capture pulls the title and company out with cheap regex heuristics (labels, “<title> at <Company>”, the URL domain) and drops them into an editable confirm you can eyeball in a second. No model call. The one model call happens on tailor, and only for jobs you actually pursue. Re-tailoring inserts a new version instead of overwriting the old one, the same append-only habit as the event log.

Rejected: an LLM call on every capture. It burns tokens on postings the user never pursues, and it makes the fast path depend on a model that might be down.

Reliability details

  • Atomic writes. The application row and its opening CREATED event are saved in one transaction. A status change and its STATUS_CHANGED event go the same way. The log can never disagree with the row.
  • Cross-database by construction. Timestamps are stored as ISO-8601 UTC strings. They sort chronologically and keep millisecond precision that second-granular SQLite datetime would lose. One schema runs unchanged on SQLite for zero-ops local dev and on Postgres in production. It is a runtime DB_TYPE switch, with no code change.
  • The cron worker snoozes. When a follow-up fires, it pushes nextFollowUpAt forward, so a quiet application is nudged once rather than every hour until the user acts.
  • Tested where it matters. Unit tests cover legal and illegal transitions, terminal locking, funnel ordering, and the tailoring heuristic’s keyword overlap plus its no-overlap fallback.

Impact

This is a portfolio system, so I will be straight about the impact: it is architectural, not a business number. What it shows is guarded state transitions, an immutable audit log the analytics are derived from, exactly-once ingestion, event-driven decoupling, and an external dependency that degrades gracefully when it is gone. Those are the same properties that matter when the domain is money instead of job applications.

↗ Read the full source on GitHub

← All work