Salesforce Development
ProjectIntermediate 60 min· AI and career · 16 of 17

Project: event booking app on Salesforce

Build a complete event booking app on Salesforce: custom objects and a roll-up, a permission set, a bulk-safe Apex trigger with tests, a Lightning Web Component for booking, a confirmation Flow, and a report and dashboard. Finish with stretch goals and a portfolio checklist.

The goal: build a small but complete event booking app on Salesforce. Organisers create events with a venue, date and seat capacity. Attendees book seats. The system refuses bookings that would go over capacity, shows how many seats are left, and sends a confirmation. At the end you have a data model, security, an Apex trigger with tests, a Lightning Web Component, a Flow, a report and a dashboard.

The finished result: a Lightning app called Event Booking with a Tech Events tab, a Bookings tab, a home page component that lists upcoming events with seats left and a Book button, a booking form that shows a clear error when an event is full, and a dashboard of bookings per event.

Prerequisites: a Developer Edition org and the earlier lessons: *Data model*, *Security model*, *Automation with Flow*, *Reports and dashboards*, *Lightning App Builder*, *SOQL and SOSL*, *Apex basics*, *Apex triggers and governor limits*, *Testing Apex* and *Lightning Web Components*. Where a step is covered in detail there, this lesson tells you what to build and gives the exact names, so that the code works as written.

Plan before you build

Write the problem in plain words first. A Tech Event has a name, a start date and time, a venue and a capacity (number of seats). A Booking belongs to one event and records who booked, their email, how many seats and a status. The rule is: confirmed seats for an event must never be more than its capacity.

The design

PieceTypeWhy
Tech Event (Tech_Event__c)Custom objectHolds the events
Booking (Booking__c)Custom object, child of Tech EventHolds each booking
Capacity checkApex before-insert triggerA rule that needs a query and totals, and must work for bulk loads
Seats leftRoll-up summary and formula fieldsCalculated by the platform, no code
ConfirmationRecord-triggered FlowAdmin-friendly automation
Events list and booking formLightning Web ComponentA custom user interface
ReportsReport and dashboardOrganisers see demand

Design choices to keep

  1. The capacity rule is in code (a trigger), so it cannot be skipped by someone using the API or an import, not only the screen.
  2. The count of seats uses a roll-up field (declarative) instead of code where possible.
  3. The trigger handles up to 200 records at a time.
  4. Users get only the access they need, through a permission set.
  5. Every piece of Apex has tests.

Stage 1: the data model

Create the objects in Setup, Object Manager. Use these exact API names so the code later matches.

Build the objects and fields

  1. 1Create the custom object Tech Event: label Tech Event, plural Tech Events, record name field Text labelled Event Name. Tick Allow Reports, Allow Search and Launch New Custom Tab Wizard after saving.
  2. 2Create the custom object Booking: label Booking, plural Bookings, record name type Auto Number with display format BK-{0000}.
  3. 3On Tech Event add fields: Start Date (Date/Time, API name Start_Date__c, required), Venue (Text 100, Venue__c) and Capacity (Number, 5 digits, 0 decimals, Capacity__c, required).
  4. 4On Booking add a field of type Master-Detail Relationship to Tech Event. The API name should be Tech_Event__c, and the related list label Bookings.
  5. 5On Booking add: Attendee Name (Text 80, required, Attendee_Name__c), Email (Email, required, Email__c), Seats (Number 3, 0 decimals, required, Seats__c) and Status (Picklist with values Confirmed, Cancelled, required, Status__c).
  6. 6Back on Tech Event, add a Roll-Up Summary field: label Seats Booked, API name Seats_Booked__c. Summarised object: Booking. Type: SUM of Seats. Filter criteria: Status equals Confirmed.
  7. 7On Tech Event add a Formula field, return type Number, 0 decimal places, label Seats Left, API name Seats_Left__c, formula: Capacity__c - Seats_Booked__c.

The master-detail link is what makes the roll-up possible, and it also means a booking has no meaning without its event (delete the event and its bookings go too). Cancelled bookings stay in the table for history but do not count, thanks to the filter on the roll-up.

Know the gap

Do not let attendees update Seats__c or Status__c after booking unless you handle the capacity check on updates too. This project checks capacity on insert. The stretch exercises add the update case.

Stage 2: security

Create a permission set instead of changing a profile. In Setup, open Permission Sets and click New. Name it Event Manager (API name Event_Manager). Give it object permissions on Tech Event and Booking (read, create, edit, and delete for managers), field permissions for all custom fields (the roll-up and formula fields are read-only), and access to the tabs. In a later stage, also add access to the Apex class EventController. Assign the permission set to yourself.

Because Booking is a detail of Tech Event, access to bookings follows the event's sharing, which is why you do not set up separate sharing rules for it.

Prompt · Review my permission design
I built a Salesforce app with custom objects Tech_Event__c and Booking__c (master-detail). I created a permission set called Event Manager with read, create, edit on both objects and access to an Apex class. Review this setup. List what might be missing, what is more access than needed, and 3 test cases I should run with a second user who has only this permission set. Keep it short.

What to expect: Check that the answer talks about field-level security, class access and testing with a separate user, not with your System Administrator account.

Stage 3: the capacity trigger

The rule needs data from other records, so a validation rule is not enough. We use a trigger that runs before a booking is inserted. In a before trigger you can mark a record as failed with addError, and the save is blocked for that record. Following the pattern from the triggers lesson, the trigger file is thin and the logic is in a service class.

The plan inside the service: (1) collect the Ids of events for the new confirmed bookings, (2) query the capacity of those events in one query, (3) query the seats already confirmed in one aggregate query, (4) walk through the new bookings, keep a running total per event, and add an error to any booking that would go over the capacity.

BookingTrigger.trigger
trigger BookingTrigger on Booking__c (before insert) {
    BookingService.checkCapacity(Trigger.new);
}
BookingService.cls
public with sharing class BookingService {

    public static void checkCapacity(List<Booking__c> newBookings) {
        Set<Id> eventIds = new Set<Id>();
        for (Booking__c b : newBookings) {
            if (b.Status__c == 'Confirmed' && b.Tech_Event__c != null) {
                eventIds.add(b.Tech_Event__c);
            }
        }
        if (eventIds.isEmpty()) {
            return;
        }

        // 1 query: capacity of each event
        Map<Id, Tech_Event__c> events = new Map<Id, Tech_Event__c>(
            [SELECT Id, Name, Capacity__c FROM Tech_Event__c WHERE Id IN :eventIds]
        );

        // 1 query: seats already confirmed for each event
        Map<Id, Decimal> booked = new Map<Id, Decimal>();
        for (AggregateResult ar : [
            SELECT Tech_Event__c eventId, SUM(Seats__c) seats
            FROM Booking__c
            WHERE Tech_Event__c IN :eventIds AND Status__c = 'Confirmed'
            GROUP BY Tech_Event__c
        ]) {
            booked.put((Id) ar.get('eventId'), (Decimal) ar.get('seats'));
        }

        // Check each new booking, including others in the same batch
        for (Booking__c b : newBookings) {
            if (b.Status__c != 'Confirmed') {
                continue;
            }
            Tech_Event__c ev = events.get(b.Tech_Event__c);
            if (ev == null) {
                continue;
            }
            Decimal soFar = booked.containsKey(ev.Id) ? booked.get(ev.Id) : 0;
            Decimal wanted = b.Seats__c == null ? 0 : b.Seats__c;
            Decimal left = ev.Capacity__c - soFar;

            if (wanted > left) {
                b.addError('Only ' + left + ' seat(s) left for ' + ev.Name + '.');
            } else {
                booked.put(ev.Id, soFar + wanted);
            }
        }
    }
}

There are two queries in total, no matter how many bookings arrive. booked is updated as each booking is accepted, so two bookings in the same batch cannot both take the last seat.

Now the tests. They cover a normal booking, an over-capacity booking and a bulk insert. In each test we create our own data and never rely on records in the org.

BookingServiceTest.cls
@IsTest
private class BookingServiceTest {

    private static Tech_Event__c makeEvent(Integer capacity) {
        Tech_Event__c ev = new Tech_Event__c(
            Name = 'Salesforce Meetup',
            Start_Date__c = System.now().addDays(10),
            Venue__c = 'Indiranagar, Bengaluru',
            Capacity__c = capacity
        );
        insert ev;
        return ev;
    }

    private static Booking__c makeBooking(Id eventId, Integer seats) {
        return new Booking__c(
            Tech_Event__c = eventId,
            Attendee_Name__c = 'Asha',
            Email__c = 'asha@example.com',
            Seats__c = seats,
            Status__c = 'Confirmed'
        );
    }

    @IsTest
    static void bookingWithinCapacityIsSaved() {
        Tech_Event__c ev = makeEvent(10);
        Database.SaveResult sr = Database.insert(makeBooking(ev.Id, 4), false);
        System.assert(sr.isSuccess());

        Tech_Event__c reloaded = [SELECT Seats_Booked__c, Seats_Left__c FROM Tech_Event__c WHERE Id = :ev.Id];
        System.assertEquals(4, reloaded.Seats_Booked__c);
        System.assertEquals(6, reloaded.Seats_Left__c);
    }

    @IsTest
    static void bookingOverCapacityIsRejected() {
        Tech_Event__c ev = makeEvent(5);
        insert makeBooking(ev.Id, 4);

        Database.SaveResult sr = Database.insert(makeBooking(ev.Id, 2), false);
        System.assert(!sr.isSuccess());
        System.assert(sr.getErrors()[0].getMessage().contains('Only 1 seat(s) left'));
    }

    @IsTest
    static void bulkInsertStopsAtCapacity() {
        Tech_Event__c ev = makeEvent(3);
        List<Booking__c> batch = new List<Booking__c>();
        for (Integer i = 0; i < 5; i++) {
            batch.add(makeBooking(ev.Id, 1));
        }
        List<Database.SaveResult> results = Database.insert(batch, false);

        Integer saved = 0;
        for (Database.SaveResult sr : results) {
            if (sr.isSuccess()) {
                saved++;
            }
        }
        System.assertEquals(3, saved);
    }

    @IsTest
    static void cancelledBookingDoesNotUseSeats() {
        Tech_Event__c ev = makeEvent(2);
        Booking__c cancelled = makeBooking(ev.Id, 2);
        cancelled.Status__c = 'Cancelled';
        insert cancelled;

        Database.SaveResult sr = Database.insert(makeBooking(ev.Id, 2), false);
        System.assert(sr.isSuccess());
    }
}

Run this class from the Developer Console (Test, New Run) or with sf apex run test. All four methods should pass.

Run and check

  1. 1Create the trigger, service class and test class in your org (VS Code with the extension pack, or the Developer Console).
  2. 2Run BookingServiceTest. If a test fails because a required field or validation rule is missing, check that the field API names and the required settings match Stage 1.
  3. 3Open the Developer Console Debug menu, Open Execute Anonymous Window, and try a manual insert that goes over capacity. You should see the error text.
  4. 4Check the code coverage for BookingService in the Developer Console. The tests above should cover almost every line.

Stage 4: the Lightning Web Component

Now the user interface. The component has an Apex method to read upcoming events, shows them as cards with seats left, and, when the user clicks Book, shows a small form. The form creates a booking with the Lightning Data Service function createRecord. If the trigger rejects the booking, the error message is shown in a toast.

EventController.cls
public with sharing class EventController {

    @AuraEnabled(cacheable=true)
    public static List<Tech_Event__c> getUpcomingEvents() {
        Datetime now = System.now();
        return [
            SELECT Id, Name, Venue__c, Start_Date__c, Capacity__c, Seats_Booked__c, Seats_Left__c
            FROM Tech_Event__c
            WHERE Start_Date__c >= :now
            WITH USER_MODE
            ORDER BY Start_Date__c
            LIMIT 50
        ];
    }
}

cacheable=true is required for @wire. WITH USER_MODE makes the query respect the user's object and field permissions.

Add EventController to the Event Manager permission set (Apex Class Access) so users can call it. Then create the component. In VS Code, run SFDX: Create Lightning Web Component and name it eventBooking, or create the three files by hand in force-app/main/default/lwc/eventBooking/.

eventBooking.html
<template>
    <lightning-card title="Upcoming events" icon-name="standard:event">
        <div class="slds-p-horizontal_medium">
            <template lwc:if={error}>
                <p class="slds-text-color_error">Could not load events. Please refresh the page.</p>
            </template>

            <template lwc:if={hasEvents}>
                <template for:each={events} for:item="evt">
                    <div key={evt.Id} class="slds-box slds-m-bottom_small">
                        <p class="slds-text-heading_small">{evt.Name}</p>
                        <p>{evt.dateLabel} at {evt.Venue__c}</p>
                        <p>Seats left: {evt.Seats_Left__c} of {evt.Capacity__c}</p>
                        <lightning-button
                            label={evt.buttonLabel}
                            variant="brand"
                            data-id={evt.Id}
                            disabled={evt.soldOut}
                            onclick={handleSelect}>
                        </lightning-button>
                    </div>
                </template>
            </template>

            <template lwc:if={noEvents}>
                <p>No upcoming events right now.</p>
            </template>
        </div>

        <template lwc:if={selectedEventId}>
            <div class="slds-p-around_medium slds-border_top">
                <p class="slds-text-heading_small slds-m-bottom_small">Book: {selectedEventName}</p>
                <lightning-input label="Your name" name="attendeeName" required onchange={handleChange}></lightning-input>
                <lightning-input label="Email" name="email" type="email" required onchange={handleChange}></lightning-input>
                <lightning-input label="Seats" name="seats" type="number" min="1" max="10" value="1" required onchange={handleChange}></lightning-input>
                <div class="slds-m-top_small">
                    <lightning-button label="Confirm booking" variant="brand" onclick={handleBook} disabled={isSaving}></lightning-button>
                    <lightning-button label="Cancel" class="slds-m-left_small" onclick={handleCancel}></lightning-button>
                </div>
            </div>
        </template>
    </lightning-card>
</template>
eventBooking.js
import { LightningElement, wire } from 'lwc';
import { createRecord } from 'lightning/uiRecordApi';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import getUpcomingEvents from '@salesforce/apex/EventController.getUpcomingEvents';

export default class EventBooking extends LightningElement {
    events = [];
    error;
    wiredResult;

    selectedEventId;
    selectedEventName;
    attendeeName = '';
    email = '';
    seats = 1;
    isSaving = false;

    @wire(getUpcomingEvents)
    wiredEvents(result) {
        this.wiredResult = result;
        if (result.data) {
            this.events = result.data.map((e) => ({
                ...e,
                soldOut: e.Seats_Left__c <= 0,
                buttonLabel: e.Seats_Left__c <= 0 ? 'Sold out' : 'Book',
                dateLabel: new Date(e.Start_Date__c).toLocaleString('en-IN', {
                    dateStyle: 'medium',
                    timeStyle: 'short'
                })
            }));
            this.error = undefined;
        } else if (result.error) {
            this.error = result.error;
            this.events = [];
        }
    }

    get hasEvents() {
        return this.events.length > 0;
    }

    get noEvents() {
        return !this.error && this.events.length === 0;
    }

    handleSelect(event) {
        this.selectedEventId = event.target.dataset.id;
        const chosen = this.events.find((e) => e.Id === this.selectedEventId);
        this.selectedEventName = chosen ? chosen.Name : '';
    }

    handleChange(event) {
        this[event.target.name] = event.target.value;
    }

    handleCancel() {
        this.selectedEventId = undefined;
    }

    handleBook() {
        const inputs = this.template.querySelectorAll('lightning-input');
        const allValid = [...inputs].every((input) => input.reportValidity());
        if (!allValid) {
            return;
        }
        this.isSaving = true;
        const fields = {
            Tech_Event__c: this.selectedEventId,
            Attendee_Name__c: this.attendeeName,
            Email__c: this.email,
            Seats__c: Number(this.seats),
            Status__c: 'Confirmed'
        };
        createRecord({ apiName: 'Booking__c', fields })
            .then(() => {
                this.toast('Booked', 'Your seats are confirmed.', 'success');
                this.selectedEventId = undefined;
                return refreshApex(this.wiredResult);
            })
            .catch((error) => {
                this.toast('Could not book', this.errorMessage(error), 'error');
            })
            .finally(() => {
                this.isSaving = false;
            });
    }

    errorMessage(error) {
        const body = error && error.body;
        if (body && body.output && body.output.errors && body.output.errors.length) {
            return body.output.errors[0].message;
        }
        return (body && body.message) || 'Unknown error';
    }

    toast(title, message, variant) {
        this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
    }
}

seats starts at 1 as a number but changes to a string when the user types, which is why Number(this.seats) is used. refreshApex reloads the list so seats left updates.

eventBooking.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>62.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Event Booking</masterLabel>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__Tab</target>
    </targets>
</LightningComponentBundle>

Use an API version your org supports (the same one as in sfdx-project.json).

Put it on a page and try it

  1. 1Deploy the Apex class and the component (VS Code: right-click the folders, SFDX: Deploy Source to Org; or use sf project deploy start).
  2. 2In Setup open Lightning App Builder, create a new App Page (one region) called Event Booking Home, drag your Event Booking component onto it, then Save and Activate.
  3. 3In Activation choose to add it to the Lightning app. Create or edit a Lightning app called Event Booking (Setup, App Manager) with the Tech Events, Bookings and the home page as navigation items.
  4. 4Create two Tech Events with different capacities, one with capacity 2.
  5. 5Open the app, book 2 seats for the small event, and confirm that it shows Sold out. Then try to book from Execute Anonymous as a test of the trigger.
What you should see (dates will differ)
Event card: Salesforce Meetup
12 Nov 2026, 6:00 pm at Indiranagar, Bengaluru
Seats left: 0 of 2
The button reads Sold out and is disabled.

On another event, a booking of 11 seats with capacity 10 shows a red toast: "Only 10 seat(s) left for ...".

Empty list

If the component shows no events, check three things: your events have a future start date; the user has the permission set with EventController access; and the component code deployed without errors. A permission problem is the most common cause.

Stage 5: confirmation with Flow

Admins prefer to control messages themselves, so use a Flow instead of Apex for the confirmation. Build a Record-Triggered Flow as in the Flow lesson.

Build the Flow

  1. 1In Setup open Flows, click New Flow, and choose Record-Triggered Flow.
  2. 2Object: Booking. Trigger: A record is created. Run the flow After the record is saved. (Use Actions and Related Records.)
  3. 3Add an Action element, choose Send Email. Set Recipient Email Addresses to the Flow's record field {!$Record.Email__c}. Subject: Your booking is confirmed. Body: use a text template that includes {!$Record.Attendee_Name__c} and {!$Record.Seats__c}.
  4. 4Save as Booking Confirmation, then Debug it with a booking record, then Activate it.

Email in a Developer Edition org can be limited. If you do not receive emails, check Setup, Deliverability, and the daily send limit. A fallback is to create a Task instead of emailing. Remember that email was only sent after the booking passed the trigger, because the Flow does not run for records that failed to save.

Stage 6: reports and dashboard

Build the report

  1. 1In Setup search Report Types, or use Reports, New Report, and choose the report type Tech Events with Bookings (it appears because of the master-detail relationship). If it is not listed, create a custom report type with Tech Events as primary and Bookings as related.
  2. 2Group rows by Event Name and add the columns Attendee Name, Seats and Status. Filter Status equals Confirmed. Add a summary of the sum of Seats.
  3. 3Save the report as Bookings by Event in a new folder.
  4. 4Create a Dashboard Event Demand and add a Bar chart from this report: events on the axis, sum of seats on the value. Add a table with the booking list if you like.

Stage 7: ship it properly

Release checklist

  1. Run all tests and confirm 100% pass.
  2. Retrieve your metadata into a Salesforce DX project and commit it to Git (see *Salesforce DX, sandboxes and deployment*). Do not commit tokens or passwords.
  3. Write a README with a screenshot, the data model, how the capacity rule works, and how to deploy.
  4. Record a one-minute demo: book seats, show Sold out, show the error toast, show the dashboard.
  5. Add it to your resume and Trailblazer profile (see *Salesforce careers and certifications*).
Prompt · Review my trigger
Review this Salesforce Apex code for bugs, governor limit risks (SOQL and DML in loops, 200-record batches), sharing and security problems, and missing tests. Explain each issue in plain English with a small fix. Do not rewrite everything.

[paste your BookingService and BookingTrigger code]

What to expect: Check that each claim matches the Apex Developer Guide. AI can wrongly call code a bug. Test every suggested change.

Prompt · Generate more test cases
Here is my Apex service class and its test class:

[paste both]

List 8 edge cases I have not tested, such as null values, zero seats, a booking in a different status, and batches of 200. For each, say what result I should assert. Then write the test method for the 3 most important ones.

What to expect: Run every test you take. Do not paste any real customer data.

Try it: Handle updates

The trigger checks only inserts. Extend it to before update: when a booking changes status to Confirmed, or Seats increases, the capacity check must run again. Take care not to count the booking being updated twice. Write tests for these cases.

Try it: Waiting list

Add a third status value, Waitlisted. When a booking would exceed capacity, instead of addError, set its status to Waitlisted. When a booking is cancelled, promote the oldest waitlisted booking if its seats now fit. Write tests.

Try it: Public booking endpoint

Use the *Integration* lesson to add an Apex REST endpoint GET /services/apexrest/events/ that returns upcoming events with seats left, and a POST that creates a booking. Test both.

Try it: Agent-ready action

Following *AI in Salesforce: Agentforce and Einstein*, write an invocable Apex method Get seats left that takes an event name and returns the seats left. Write its description so that an AI agent could choose it correctly.

Key takeaways

  1. Plan from the business rule first: the rule here is that confirmed seats can never exceed capacity.
  2. A master-detail relationship plus a filtered roll-up summary gives you seat totals with no code.
  3. Rules that need other records belong in a bulk-safe trigger with logic in a service class: two queries however many bookings arrive, a running total per event.
  4. Always test the normal case, the failing case, bulk and the edge cases such as cancelled bookings.
  5. A Lightning Web Component uses an Apex method for reading (@wire with cacheable=true) and createRecord for saving, and shows trigger errors to the user.
  6. Use a permission set for access and test with a user who is not a System Administrator.
  7. Use Flow for admin-controlled automation such as emails, and put the work in Git with a README and demo so others can see it.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why is the capacity rule written as a trigger and not only in the component's JavaScript?

2. Why does BookingService keep a running total in the booked map while it loops over the new bookings?

3. What makes the Seats Booked roll-up summary field possible?

4. In the LWC, why does the wire adapter's Apex method need cacheable=true?

5. A user sees an empty events list although events exist with future dates. What should you check first?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free