Skip to content

Event vs EventSession: which row is the actual SKU

The decision this framework is for

An event-ticketing platform sells experiences that have a curious shape: a single artist tour visits five cities, each city has three performances on different nights, every performance has its own pricing tiers, and any one of those nights can be cancelled, rescheduled, or sold out independently of the others. The customer searches for "the show," the venue manager pauses sales on "the Friday night," the finance team reconciles sales on "the September weekend." Each of those queries uses the same word — "show," "event," "performance" — to mean different things.

The decision is what row, in what model, is the actual unit of commerce. This is the SKU question. Get it wrong, and every later feature — variant pricing, partial cancellations, reschedules, multi-night passes — works against the schema instead of with it.

The framework — Event vs EventSession as the SKU

The framework names two models with deliberately small responsibilities.

Event is the marketing entity. It is what the customer searches for, what the artist's name links to, what the SEO pages target. It has a name, a description, a banner image, a list of supporting artists, a category, and a slug for routing. It does not have a date, a venue, or a price.

EventSession is the SKU. It is what the Order references. It has a date, a start time (in local wall-clock plus a timezone, see related post), a venue, a pricing structure per section, a paused-or-not flag, and a selling-window open time. It does not have a description or a banner image — those belong to the Event.

The customer interacts with the Event ("Concert by Artist X"). The Order references the EventSession ("Friday, October 11 at Venue Y"). The seller manages both — the Event once when the show is created, the EventSession N times for each night the show plays.

Each step with one paragraph of explanation

Event holds the things that do not change per performance. The artist, the description, the imagery, the category — all the fields a customer reads before they decide to buy a ticket. These are shared across every night of the tour. Putting them on a per-night row would duplicate the data and make a description fix into an N-row update.

EventSession holds everything that varies per performance. The venue, the date, the local-time start, the pricing per section, the paused flag, the selling-window open. These vary because each performance is its own logistical event. Putting them on the Event would make "different prices in Berlin and Paris" impossible, and would make pausing a single night require pausing the whole show.

Order references EventSession, not Event. A customer buys a ticket to a specific night, at a specific venue, with a specific seat. The Order's eventSessionSlug is the foreign key. Asking "what shows is this customer attending" is a query that joins Order to EventSession to Event — but the storage column is the session id.

Search indexes the Event; results expand to EventSessions. The customer searches "Artist X." The search hits the Event. The result page lists every upcoming EventSession for that Event, grouped by city. The customer picks a city, sees the dates available, picks a date, lands on the seating chart for that EventSession. Search and discovery sit at the Event level. Purchase commits at the EventSession level.

The validator queries through EventSession to the venue. At the door, the QR resolves to an Order, which resolves to an EventSession, which resolves to a Venue and a start time. The validator checks that the EventSession matches the venue's gate, that the start time has arrived (entry opens an hour before), and that the seat is in the matching session's chart. The Event itself is irrelevant to the validator — it does not know whether the show is part of a tour or a one-off.

Walk the framework through a real artifact

The schema lays the two models out side by side. Each one is intentionally lean:

model Event {
  id                 String        @id @default(cuid())
  slug               String        @unique
  name               String
  image              String?
  description        String?       @db.Text
  eventCategorySlug  String
  artists            EventArtist[]
  eventSessions      EventSession[]
  createdAt          DateTime      @default(now())
  updatedAt          DateTime      @updatedAt
}

model EventSession {
  id                  String              @id @default(cuid())
  slug                String              @unique
  eventSlug           String
  venueSlug           String
  startsAtLocal       DateTime
  endsAtLocal         DateTime
  startSellingAt      DateTime
  paused              Boolean             @default(false)
  sectionPrices       Json?               // [{ sectionId, priceMinor }]
  event               Event               @relation(fields: [eventSlug], references: [slug])
  venue               Venue               @relation(fields: [venueSlug], references: [slug])
  eventSessionOrders  EventSessionOrder[]
}

A category goes on the Event because the category — "Pop," "Jazz," "Theatre" — is a property of the show, not the night. Pricing goes on the EventSession because the same tour charges different prices in different cities. The selling-window open time goes on the EventSession because some nights go on sale earlier than others.

The Event's content is what the customer reads:

// app/(frontend)/events/[eventSlug]/page.tsx
export default async function EventPage({ params }: { params: { eventSlug: string } }) {
  const event = await prisma.event.findUnique({
    where: { slug: params.eventSlug },
    include: {
      artists: { include: { artist: true } },
      eventSessions: {
        where: { startsAtLocal: { gte: new Date() } },
        include: { venue: { include: { city: true } } },
        orderBy: { startsAtLocal: 'asc' },
      },
    },
  });
  if (!event) return notFound();
  return (
    <article>
      <EventHero event={event} />
      <EventDescription event={event} />
      <SessionList sessions={event.eventSessions} />
    </article>
  );
}

The page is "the show." The SessionList underneath is "where you can buy." The customer clicks a session, which routes to the per-session purchase page that owns the seating chart and the Order.

The seller's admin view inverts the relationship. The seller creates the Event once and then schedules N EventSessions against it:

// app/(api)/api/private/events/[eventSlug]/sessions/route.ts
export async function POST(req: Request, { params }: Params) {
  const body = await req.json();
  const session = await prisma.eventSession.create({
    data: {
      slug: generateSessionSlug(params.eventSlug, body.venueSlug, body.startsAtLocal),
      eventSlug: params.eventSlug,
      venueSlug: body.venueSlug,
      startsAtLocal: new Date(body.startsAtLocal),
      endsAtLocal: new Date(body.endsAtLocal),
      startSellingAt: new Date(body.startSellingAt),
      sectionPrices: body.sectionPrices,
    },
  });
  return Response.json(session);
}

The session inherits the Event's description and imagery automatically by virtue of the foreign key. The seller does not re-enter the artist's name for the fifteenth time on the fifteenth night of the tour.

The Order references the EventSession by slug:

model EventSessionOrder {
  id                     String       @id @default(cuid())
  pnrNumber              String       @unique
  eventSessionSlug       String
  userId                 String
  status                 OrderStatus  @default(PENDING)
  expireIfNotCompletedAt DateTime?
  totalAmountMinor       Int
  currency               String
  eventSession           EventSession @relation(fields: [eventSessionSlug], references: [slug])
  seats                  Seat[]
}

Every customer-facing report ("here are your upcoming tickets") joins Order → EventSession → Event. Every seller-facing report ("how much did this night sell") groups Order by eventSessionSlug. The grain of the report matches the grain of the SKU.

Where the framework fails

Tour-wide promotions. Selling "a pass to all four nights" needs a third entity — a TourPass or MultiSession — that references multiple EventSessions and gets refunded as a unit. The framework does not extend cleanly. The team's pragmatic answer was to ship multi-session passes as a one-off composite Order that carries multiple EventSession references, with a flag on the Order. The schema accepts this but the reporting becomes special-case. A future migration to a real Pass model is on the backlog for the venue that drove the requirement.

Reschedules. When a single night of a tour is rescheduled (artist falls ill, venue conflict), the EventSession's startsAtLocal changes. The Orders already attached to that session are now valid for the new date, which is usually what the customer wants. But customers want a clear email saying "your ticket is now for date X, original was date Y." The framework does not store the old date — the field just updates. The team added a rescheduledFrom: DateTime? column to keep the history, but the cleanest version of this would be an EventSessionRevision audit table. Not yet written.

Cancellations of one session in a tour. Cancelling a single EventSession means cancelling every Order against it and triggering refunds. The Order status machine handles this — status = CANCELLED — and the refund flow runs against the related orders. The Event itself is unaffected. This is the framework working as intended; the cost is the operational discipline to know which model to target.

CTA — the prompt that triggers the framework

If you are about to model an event-like commerce flow and the temptation is to fold "the show" and "the night" into one model, ask: can the customer buy a ticket without picking a specific date? If the answer is no, you have two entities, and the second one is the SKU. The first one is the marketing surface. Keep them separate from the first migration.

If you have an existing schema where the Event is the SKU, instrument first. Find the queries where "all sessions for an Event" gets re-derived from the wrong row. Those are the queries that prove the schema is fighting the domain. The migration to split the model is more work than starting cleanly, but the queries that disappear from the codebase are the ones that earn it back.

Trade-off

The two-model split means every Order-related query has to join through the EventSession to the Event when it wants marketing fields. The join is cheap (one indexed lookup), but it has to be remembered. The shape that comes out the other side is two rows in the response; the application has to pick which fields to display.

What you give up is the convenience of "everything about a show is in one row." What you gain is correctness on every later feature. Per-session pricing works. Per-session pausing works. Per-night refunds work. Multi-city tours work. The features that look impossible against a single-Event model become straightforward against a two-model split.

Business impact

The split makes the venue manager's life smaller. They create the Event once, then add nights against it as the artist's tour schedule firms up. Each night has its own controls — pause sales, change prices, reschedule — that do not affect any other night. The customer's life is also smaller: they see one Event page with a list of dates and pick the night that fits their schedule.

For the platform, the split makes the data model match the language the team uses to talk about the business. The marketing team owns "the event." The operations team owns "the session." The customer support team has both terms for the right reasons. The schema names the thing each team is responsible for, and the queries follow.

What to do next

If you are starting fresh, ship the two-model split from the first migration. Event holds the description and the artist links; EventSession holds the date, the venue, and the pricing. Orders reference the session. Every customer-facing page joins Event for content and EventSession for the buy button.

If you have a single-Event-as-SKU schema, the migration is real but bounded. Add the EventSession model, backfill one session per existing Event with the existing date/venue/pricing, point new Orders at the session id, and let the old foreign key fall away in a later release. The application code changes are mostly in the order-creation path and the chart render — the rest is shape that follows.

The artifact to copy: an Event model with name, description, artists, category; an EventSession model with date, venue, pricing, paused-flag; an Order model that references the session by slug; and a customer-facing page that joins both. Three models, two roles, one commerce flow that fits the domain.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox