Skip to content
CHRISTOPHER KILO.

Case study · Web Application

Event Horizon

Event Horizon is an event discovery app with a curated catalog you can reserve and real Ticketmaster listings you can browse. Native events hold tickets on the site. External events send you to the provider.

Frontend

Next.jsReactTypeScriptTailwind CSS

Backend / data

PostgreSQLPrismaAuth.js

AWS cloud

ECS/FargateLambdaSQSDynamoDBEventBridgeSSMIAMCloudWatchCDK

Integration

DockerTicketmaster Discovery API

Project snapshot

At a glance

Role
Full-Stack / Cloud Developer
Project type
Event discovery web application
Frontend
Next.js 16 · React 19 · TypeScript · Tailwind CSS
Backend
PostgreSQL · Prisma · Auth.js
Cloud
AWS CDK · Lambda · SQS · DynamoDB · ECS Fargate
Testing
Unit · integration · Playwright · accessibility
Architecture
Asynchronous provider ingestion with a DLQ and idempotent DynamoDB writes. Native reservations stay on PostgreSQL.
Status
Live demo

Refresh cadence

Scheduled Fargate runs each day (8 AM / 8 PM Chicago)

Problem

Reservations and provider catalogs are not the same workload. Holds, inventory, users, and favorites need transactional consistency. External listings need ingestion, normalization, deduplication, retries, expiration, and a refresh schedule. Forcing both into one database would either weaken inventory guarantees or make a discovery cache pretend to be a booking system.

Approach

I split booking from discovery. Native events, inventory, users, and reservations stay transactional. Provider listings land in a cache and never enter the reservation APIs. The native catalog still renders if the provider path is down.

How it works

EventBridge starts the Fargate worker at 8:00 AM and 8:00 PM America/Chicago. The container calls Ticketmaster Discovery, posts normalized messages to SQS, and exits. An ingestion Lambda validates each message and upserts DynamoDB: first-seen ingestedAt stays put, updatedAt moves forward, and records expire about seven days after start. The UI never talks to DynamoDB. It calls a Query-only Lambda over HTTPS. Native browse and checkout still go through Next.js APIs and Prisma transactions.

  1. Ticketmaster
  2. EventBridge
  3. Fargate
  4. SQS
  5. Lambda
  6. DynamoDB
  7. Read Lambda
  8. Event Horizon

Architecture highlight

Cloud event pipeline

A short-lived importer, not a standing cluster. Write path and read path stay separate so the browser never needs AWS credentials.

Ingest

  1. Ticketmaster
  2. Fargate
  3. SQS
  4. Lambda
  5. DynamoDB

Read

  1. DynamoDB
  2. Read Lambda
  3. Event Horizon

Two persistence paths

One Next.js app. Booking and provider discovery do not share a database.

Shared entry

  1. User
  2. Event Horizon / Next.js

Native application

Transactional path. Reservations, inventory, users, and favorites stay on PostgreSQL via Prisma.

  1. Event Horizon UI
  2. Next.js APIs
  3. Prisma
  4. PostgreSQL

External discovery

Provider cache. Listings are stored by provider + externalId and shown as outbound cards.

  1. Ticketmaster
  2. EventBridge
  3. Fargate
  4. SQS
  5. Lambda
  6. DynamoDB
  7. Read Lambda
  8. UI

Engineering decisions

Why two stores?

Reservations need transactions on PostgreSQL and inventory that cannot go negative. Provider catalogs need upsert-by-external-id and a TTL, which is the DynamoDB cache. I kept both stores rather than stretching one model across two jobs.

Why SQS?

Collection and persistence should not share a process. The worker can finish even if DynamoDB is briefly unhappy; Lambda can retry a single bad payload without blocking the rest of the batch. A DLQ after three receives keeps a poison message from looping forever.

Why Fargate, not an ECS Service?

The Ticketmaster importer is a Dockerized TypeScript worker. It only needs to run when the schedule fires, talk to Ticketmaster and SQS, then exit. Fargate fits that shape: no always-on ECS Service. Public subnets with a public IP keep NAT Gateway cost out of a learning account.

Why two Lambdas?

Ingestion is UpdateItem-only. The reader is a separate Query-only Function URL. Mixing those permissions would blur a write processor with a public read API. A Function URL is enough for GET JSON from one provider.

Verification

Built, not mocked.

What was deployed, executed, or observed.

  • Deployed

    The CDK infrastructure has been deployed successfully, including the worker, ingestion path, discovery store, and reader API.

  • Tested

    Unit, integration, and Playwright coverage validate browsing, reservations, external-event mapping, and the case-study route.

  • Secure

    Provider credentials live in SSM, while IAM permissions are scoped separately for worker, ingestion, and reader responsibilities.

  • Resilient

    A malformed message was verified to move to the DLQ after three receives; repeat ingestion preserves the original ingestion timestamp.

  • Accessible

    Keyboard, semantic, and axe checks pass on the case-study surface.

Known limits

  • External listings are discovery-only and never enter Event Horizon checkout.
  • If the public reader is missing or fails, the native catalog still renders.

Current state

Visitors can browse seeded Event Horizon events and real Dallas Ticketmaster listings in one UI. Native events take authenticated holds against inventory. External cards show venue, city, and date, then open Ticketmaster or Ticketweb.

Implemented

  • Auth.js Google OAuth with database sessions
  • Browse API with URL-synced filters and source distinction
  • Transactional reservations with idempotent creates
  • Dockerized Ticketmaster worker on short-lived Fargate
  • SQS ingestion with DLQ after three failures
  • Idempotent DynamoDB upserts and Query-only public reader
  • EventBridge schedule at 8 AM / 8 PM America/Chicago

Demo / seeded

  • 16 fictional seeded native events
  • Up to 20 Ticketmaster events per current ingestion run
  • Confirmed status means held, not paid

What I learned

The useful lesson was matching storage to workload instead of collecting AWS logos. Once reservations stayed on Postgres and discovery stayed on DynamoDB, IAM, retries, and the UI contract got simpler: one path books seats, the other only lists what a provider already sells.

Next

  1. 01Add Stripe (or similar) so confirmed stops meaning a pre-payment hold
  2. 02Serve native detail/home inventory from the API so remaining tickets match Postgres
  3. 03Expose browse pagination controls the API already supports
  4. 04Add database integration tests for reservation race conditions