Skip to content

General admission or seated: one boolean that branches the entire purchase UI

10/7/2024Mobile DevelopmentEvent Ticketing Platform12 min read

The decision and why it cannot be deferred

An event-ticketing platform sells two fundamentally different kinds of experience. Some shows are general admission — you bought a ticket, you stand wherever you want, the venue is a single capacity counter. Other shows are seated — you bought seat A14 in Section 3, and you sit in A14 in Section 3, and nobody else does. From the customer's perspective these are two products. From the platform's perspective they have to be modelled in the same database, edited in the same admin, displayed in the same chart component, and validated by the same door scanner.

The decision is how to express the difference. One model with a flag, or two models with a shared interface. The decision cannot be deferred because it shapes the schema, the purchase flow, the layout editor, and the validator — all four of which are downstream of "is this a seat or a slot." Get it wrong on the first migration and the refactor costs more than the rest of the platform combined.

Option A — Two separate models

Build Section and Seat for seated venues; build StandingZone and Pass for general-admission venues. Each one has its own routes, its own purchase flow, its own admin editor, its own validator path. The schema is fully normalised: every model expresses exactly the data it needs and nothing more.

// Hypothetical separate-models approach
model Section {
  id        String @id @default(cuid())
  venueSlug String
  name      String
  seats     Seat[]
}

model Seat {
  id        String @id @default(cuid())
  sectionId String
  name      String
}

model StandingZone {
  id        String @id @default(cuid())
  venueSlug String
  name      String
  capacity  Int
  passes    Pass[]
}

model Pass {
  id          String @id @default(cuid())
  zoneId      String
  orderId     String
}

What you get is clean separation. The seated code path never has to check whether the section is standing. The standing code path never has to think about per-seat layout. Each model is what it says it is.

What you pay is duplication everywhere. The customer-facing chart has two render paths. The admin editor has two layouts. The validator has two queries. The order model has to relate to either seats or passes — which means an OrderSeat table and an OrderPass table, and any order-level operation (refund, transfer, cancellation) has to handle both. The platform doubles its surface for the same set of features.

The deeper problem is that the boundary is not stable. A venue that runs both kinds of shows — a stadium that hosts a seated theatre performance one weekend and a standing-only DJ set the next — needs to flip between models for the same physical room. Two separate models means two separate venue records, two separate layouts, and a manual reconciliation every time the same venue appears in both contexts.

Option B — One model with a binary

A Venue has a standing: boolean flag. A Section has a standing: boolean flag. When standing = false, the section has children seats and renders as a seated chart. When standing = true, the section has no children — it has a capacity field and renders as a single block. The order links to seats (for seated) or to a standing-section row (for standing) through the same many-to-many relation, with a discriminator field on the join.

model Venue {
  id        String    @id @default(cuid())
  slug      String    @unique
  name      String
  standing  Boolean   @default(false)
  sections  Section[]
}

model Section {
  id        String  @id @default(cuid())
  venueSlug String
  name      String
  standing  Boolean @default(false)
  capacity  Int?            // nullable; required when standing = true
  seats     Seat[]          // empty when standing = true
  // ...positionX, positionY, sizeX, sizeY, etc.
}

model Seat {
  id        String  @id @default(cuid())
  sectionId String
  // ...positionX, positionY, etc.
  section   Section @relation(fields: [sectionId], references: [id])
}

The customer flow is the same component. The seated chart is the canvas with seats; the standing chart is the same canvas with the section rendered as a single clickable rectangle and a "Tickets Available: N" badge. The order-creation logic for seated decrements seat availability by claiming a row; the order-creation logic for standing decrements the section's capacity counter atomically. The admin editor builds both venues with the same drag-and-drop tool, with a toggle on the venue: "Standing only" — which when on, hides the per-section seat editor and shows a capacity field instead.

This is the option the platform shipped with.

Option C — Polymorphic via a unit interface

A third path exists: model a Unit interface that has Seat and Pass as subtypes, with shared columns (sectionId, orderId, status) and a discriminator. Postgres supports this via single-table inheritance or via separate tables with a union view.

The trade is theoretical purity versus query-time gymnastics. Every customer-facing query has to either query the union view (slow, no indexes on subtype-specific columns) or query each subtype separately (defeats the purpose). Most teams that go down this path back out within a quarter because the query layer becomes the bottleneck.

The platform considered and rejected this option. It is the right answer for a system where the subtypes have genuinely different shapes (different fields, different relations to other models). For Seat vs Pass, the subtypes have ninety percent of the same fields and ten percent that differ. The boolean discriminator on a single model is cheaper than the polymorphic interface.

The deciding factor and the artifact that justifies it

The deciding factor was the customer-facing chart. The same React component had to render both kinds of venue, and the platform needed to be able to flip a venue from seated to standing (or vice versa) for a single event without rebuilding the layout. The boolean satisfies both. The component checks section.standing and either renders the seats or renders a single block:

function SectionRenderer({ section, soldCount }: Props) {
  if (section.standing) {
    const remaining = (section.capacity ?? 0) - soldCount;
    return (
      <div
        className="section section--standing"
        style={transformStyle(section)}
      >
        <span className="section-name">{section.name}</span>
        <span className="section-availability">{remaining} available</span>
      </div>
    );
  }
  return (
    <div className="section section--seated" style={transformStyle(section)}>
      {section.seats.map((seat) => (
        <SeatRenderer key={seat.id} seat={seat} />
      ))}
    </div>
  );
}

One component. Two render paths. The branching is on a column that already exists for layout reasons.

The order-creation path has the same structure. A single endpoint reads the section's standing flag and routes the request:

// app/(api)/api/gateway/[orderId]/sections/[sectionId]/claim/route.ts
export async function POST(req, { params }) {
  const { orderId, sectionId } = params;
  const { count, seatIds } = await req.json();
  const section = await prisma.section.findUnique({ where: { id: sectionId } });
  if (!section) return Response.json({ error: 'no-section' }, { status: 404 });

  if (section.standing) {
    return claimStandingPasses(orderId, section, count);
  }
  return claimSeats(orderId, section, seatIds);
}

async function claimStandingPasses(orderId, section, count) {
  return prisma.$transaction(async (tx) => {
    const live = await tx.eventSessionOrder.findMany({
      where: {
        eventSessionSlug: { /* current session */ },
        status: { in: ['PENDING', 'COMPLETED'] },
        standingClaims: { some: { sectionId: section.id } },
      },
      include: { standingClaims: true },
    });
    const used = live.reduce(
      (sum, o) => sum + o.standingClaims.find((c) => c.sectionId === section.id).count,
      0,
    );
    if (used + count > section.capacity) {
      return Response.json({ error: 'over-capacity' }, { status: 409 });
    }
    await tx.eventSessionOrder.update({
      where: { id: orderId },
      data: {
        standingClaims: {
          upsert: {
            where: { orderId_sectionId: { orderId, sectionId: section.id } },
            create: { sectionId: section.id, count },
            update: { count },
          },
        },
      },
    });
    return Response.json({ ok: true });
  });
}

async function claimSeats(orderId, section, seatIds) {
  // Connect seats to the order via the many-to-many relation; the seat-hold
  // contract handles concurrency.
  await prisma.eventSessionOrder.update({
    where: { id: orderId },
    data: { seats: { connect: seatIds.map((id) => ({ id })) } },
  });
  return Response.json({ ok: true });
}

The two functions share a contract (return a 409 on conflict, a 200 on success) and differ only where they have to: one updates a counter, the other connects rows. The route handler is the if-else; the rest is shared infrastructure.

Where the framework fails

The framework fails when a single section needs to be partly seated and partly standing. A stadium with a seated lower bowl and a standing pit cannot be expressed as one section; each part has to be its own Section, with its own standing flag. This is the right modelling for the customer-facing chart (the pit is its own clickable area), but the team had to write a small piece of admin tooling to express "this venue has both kinds of sections" without confusing the venue manager.

It also fails when standing-room ticket types stratify. A simple capacity counter on the section handles "X passes available." A standing section with two price tiers (early-bird vs day-of, or general-standing vs front-of-stage) needs a separate table to track each tier's count. The platform pushed that into a StandingTier model that hangs off the Section — adding complexity exactly where it is needed, while leaving the simple single-tier case unchanged.

CTA — the question that flips the decision

The decision flips when the standing experience needs significantly more domain logic than the seated experience. A festival with multi-day passes, in-and-out re-entry, and tiered standing zones may be better served by a Pass model that does not pretend to share an interface with Seat. For most event-ticketing flows — concerts, theatre, sports — the differences are smaller than the similarities, and one model is the right call.

The question to ask is: how many fields on the standing side are genuinely different from the seated side? If the answer is fewer than three, the boolean wins. If the answer is more than five, the separate model is the cheaper long-term choice.

Trade-off

The shared model means every query that operates on sections has to be aware of the standing flag. WHERE section.standing = false shows up in every seated-only query. Customer-facing chart code has to branch on the flag in every render. The integrity scan has to skip standing sections when checking "no empty sections allowed."

What you give up is purity. What you gain is structure. The shared schema means the order model is the same shape regardless of venue type, which means refunds, cancellations, transfers, and reconciliation reports all work the same way without special cases. The cost of the conditional is paid line-by-line, in places where it is locally cheap. The cost of two separate models would be paid file-by-file, in places where it is locally expensive.

Business impact

A venue that flips between modes — a multi-purpose room that hosts seated theatre one night and standing concerts the next — works in the platform with a single admin action. The venue manager toggles the section's standing flag, sets a capacity, saves. The customer-facing chart re-renders the next time it loads. No re-onboarding, no second venue record, no engineering involvement.

This matters for venues that are not single-purpose. Football stadiums host concerts. Theatres host stand-up comedy. Conference halls host trade shows. The platform's ability to support the same physical space in two configurations without operational overhead is what makes "we work for any venue" a real claim rather than a marketing one.

What to do next

If you are starting fresh and your subtypes have ninety percent overlap, model one entity with a discriminator and stop. The first time you need a subtype-specific field, add it as a nullable column with a check constraint ("this column is required when standing = true"). The schema stays flat, the queries stay simple, and the customer-facing surface treats the variants as one product.

If you have two parallel models and the operational cost is starting to show, the consolidation is real work but bounded. Pick the order-creation path first, then the chart render, then the admin editor. The schema migration is the last step, after the application has been operating on a unified read interface for long enough that the rollback is safe.

The artifact to copy: a standing: boolean on the Section, a capacity: int? that is required when standing is true, a single component that branches on the flag, and a single order-claim endpoint that routes to two small handlers behind the same response contract. Four pieces. One model. Two products that look the same in the database and feel right to the customer.

Related Articles

Same Category

Comments (0)

Newsletter

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