Emmanuel Akinfulubi

The Project

A seat-booking system for an interstate bus park, built in eight steps. Each step runs on its own so you can read it in isolation, and the last one is all eight assembled and working. Read a step, then rebuild it from memory in the playground before moving on.

The brief

  • Four rows, A to D, eight seats each. Rows A and B are luxury, C and D are economy.
  • Luxury seats cost ₦18,000, economy ₦12,000. One booking fee of ₦200 per booking, not per seat.
  • Student, senior and group discounts apply to seat prices but never to the fee.
  • A seat can be held by exactly one booking. Trying to take a held seat must fail in a way the caller can recover from.
  • A booking can gain and lose seats until it is confirmed. After that it is immutable.
  • An empty booking cannot be confirmed.
  • Other parts of the system are told about bookings without the trip knowing who is listening.
  • Storage must be replaceable without touching anything else.
Step 01

A seat that cannot be wrong

Start at the smallest thing. A seat has a row and a number, both constrained, and once made it never changes. An immutable value object is the easiest kind of object to reason about, so build those first.

Step 1: A seat that cannot be wrong

class Seat {
  static ROWS = Object.freeze(['A', 'B', 'C', 'D']);
  static PER_ROW = 8;

  constructor(row, number) {
    if (!Seat.ROWS.includes(row)) {
      throw new RangeError(`Row "${row}" does not exist. Rows are ${Seat.ROWS.join(', ')}.`);
    }
    if (!Number.isInteger(number) || number < 1 || number > Seat.PER_ROW) {
      throw new RangeError(`Seat number must be 1 to ${Seat.PER_ROW}, got ${number}.`);
    }
    this.row = row;
    this.number = number;
    Object.freeze(this);
  }

  get label() { return `${this.row}${this.number}`; }

  // A tier derived from the row, so it cannot disagree with the seat.
  get tier() { return this.row === 'A' || this.row === 'B' ? 'luxury' : 'economy'; }

  toString() { return this.label; }

  // Two seats with the same row and number are the same seat.
  equals(other) { return other instanceof Seat && other.row === this.row && other.number === this.number; }

  static parse(text) {
    const match = /^([A-Da-d])(\d{1,2})$/.exec(String(text).trim());
    if (!match) throw new SyntaxError(`Cannot read "${text}" as a seat.`);
    return new Seat(match[1].toUpperCase(), Number(match[2]));
  }
}

const a = Seat.parse('d4');
console.log(String(a), '|', a.tier);
console.log('same seat?', a.equals(new Seat('D', 4)), '| different:', a.equals(new Seat('D', 5)));

// Frozen, so the write is refused. In strict mode refusing means throwing.
try { a.row = 'A'; } catch (err) { console.log(err.name + ':', err.message); }
console.log('unchanged:', a.label);

try { new Seat('Z', 1); } catch (err) { console.log('refused:', err.message); }
try { Seat.parse('hello'); } catch (err) { console.log('refused:', err.message); }

Output


      

What this step teaches. Value objects, static constants, named constructors, freezing, and a parse method that keeps validation in one place.

Step 02

Pricing as a strategy, not an if

Prices change more often than anything else in a booking system. Keep them out of the classes that model seats and bookings, so a new discount never touches working code.

Step 2: Pricing as a strategy, not an if

/* Carried over from step 1, with the validation trimmed so the new code
   below stays the point. Nothing here is different in kind. */
class Seat {
  static ROWS = Object.freeze(['A', 'B', 'C', 'D']);
  static PER_ROW = 8;
  constructor(row, number) { this.row = row; this.number = number; Object.freeze(this); }
  get label() { return this.row + this.number; }
  get tier() { return this.row === 'A' || this.row === 'B' ? 'luxury' : 'economy'; }
  toString() { return this.label; }
}

/* [ new in this step ] */
class PriceList {
  #base;
  #fee;

  constructor({ luxury, economy, fee }) {
    this.#base = { luxury, economy };
    this.#fee = fee;
  }

  seatNaira(seat) { return this.#base[seat.tier]; }
  get feeNaira() { return this.#fee; }
}

// Each discount is an object with one method. Adding one adds nothing to edit.
const FULL = { label: 'full price', apply: (p) => p };
const STUDENT = { label: 'student', apply: (p) => Math.round(p * 0.8) };
const SENIOR = { label: 'senior', apply: (p) => Math.round(p * 0.75) };
const GROUP = { label: 'group of 6+', apply: (p) => Math.round(p * 0.7) };

class Pricing {
  #list;
  #discount;

  constructor(list, discount = FULL) {
    this.#list = list;
    this.#discount = discount;
  }

  with(discount) { return new Pricing(this.#list, discount); }

  get discountLabel() { return this.#discount.label; }

  quote(seats) {
    if (seats.length === 0) return { subtotal: 0, fee: 0, total: 0 };
    const subtotal = seats.reduce((n, s) => n + this.#discount.apply(this.#list.seatNaira(s)), 0);
    const fee = this.#list.feeNaira;
    return { subtotal, fee, total: subtotal + fee };
  }
}

const list = new PriceList({ luxury: 18000, economy: 12000, fee: 200 });
const seats = [new Seat('A', 1), new Seat('D', 4)];

for (const discount of [FULL, STUDENT, SENIOR, GROUP]) {
  const pricing = new Pricing(list, discount);
  const q = pricing.quote(seats);
  console.log(pricing.discountLabel.padEnd(12), 'subtotal', q.subtotal, '+ fee', q.fee, '= ', q.total);
}

Output


      

What this step teaches. Strategy, dependency injection, and returning a new configured object from with() instead of mutating.

Step 03

A booking that enforces its own rules

Now the object with real rules. A booking can gain seats until it is confirmed, cannot take the same seat twice, and cannot be confirmed empty. Every one of those rules lives inside it.

Step 3: A booking that enforces its own rules

/* Carried over from steps 1 and 2, trimmed. */
class Seat {
  static ROWS = Object.freeze(['A', 'B', 'C', 'D']);
  static PER_ROW = 8;
  constructor(row, number) { this.row = row; this.number = number; Object.freeze(this); }
  get label() { return this.row + this.number; }
  get tier() { return this.row === 'A' || this.row === 'B' ? 'luxury' : 'economy'; }
  toString() { return this.label; }
}

class PriceList {
  #base; #fee;
  constructor({ luxury, economy, fee }) { this.#base = { luxury, economy }; this.#fee = fee; }
  seatNaira(seat) { return this.#base[seat.tier]; }
  get feeNaira() { return this.#fee; }
}

const FULL = { label: 'full price', apply: (p) => p };

class Pricing {
  #list; #discount;
  constructor(list, discount = FULL) { this.#list = list; this.#discount = discount; }
  quote(seats) {
    if (!seats.length) return { subtotal: 0, fee: 0, total: 0 };
    const subtotal = seats.reduce((n, s) => n + this.#discount.apply(this.#list.seatNaira(s)), 0);
    return { subtotal, fee: this.#list.feeNaira, total: subtotal + this.#list.feeNaira };
  }
}

/* [ new in this step ] */
class Booking {
  static #next = 1;

  #reference;
  #seats = new Map();     // label -> Seat, so duplicates are impossible
  #confirmed = false;
  #pricing;

  constructor(pricing) {
    this.#pricing = pricing;
    this.#reference = 'BK-' + String(Booking.#next++).padStart(4, '0');
  }

  get reference() { return this.#reference; }
  get confirmed() { return this.#confirmed; }
  get seats() { return [...this.#seats.values()]; }
  get count() { return this.#seats.size; }
  get quote() { return this.#pricing.quote(this.seats); }
  get total() { return `₦${this.quote.total}`; }

  add(seat) {
    this.#assertOpen();
    if (this.#seats.has(seat.label)) {
      throw new RangeError(`${seat.label} is already on ${this.#reference}.`);
    }
    this.#seats.set(seat.label, seat);
    return this;
  }

  remove(seat) {
    this.#assertOpen();
    if (!this.#seats.delete(seat.label)) {
      throw new RangeError(`${seat.label} is not on ${this.#reference}.`);
    }
    return this;
  }

  confirm() {
    this.#assertOpen();
    if (this.#seats.size === 0) throw new Error('Cannot confirm an empty booking.');
    this.#confirmed = true;
    return this;
  }

  #assertOpen() {
    if (this.#confirmed) throw new Error(`${this.#reference} is confirmed and can no longer change.`);
  }

  toString() {
    return `${this.#reference}: ${this.seats.join(', ') || 'empty'} — ${this.total}`;
  }

  toJSON() {
    return {
      reference: this.#reference,
      seats: this.seats.map(String),
      confirmed: this.#confirmed,
      totalNaira: this.quote.total,
    };
  }
}

const pricing = new Pricing(new PriceList({ luxury: 18000, economy: 12000, fee: 200 }));

const booking = new Booking(pricing).add(new Seat('A', 1)).add(new Seat('A', 2));
console.log(String(booking));

try { booking.add(new Seat('A', 1)); } catch (err) { console.log('refused:', err.message); }

booking.confirm();
console.log('confirmed:', booking.confirmed);
try { booking.add(new Seat('B', 1)); } catch (err) { console.log('refused:', err.message); }
console.log('serialised:', JSON.stringify(booking));

Output


      

What this step teaches. Encapsulation, a private guard method, private static id issuing, and toJSON for a deliberate serialised shape.

Step 04

Typed errors, so callers can respond

So far the failures throw plain Error and RangeError. A caller cannot tell them apart without reading message text, which breaks the first time you reword a message.

Step 4: Typed errors, so callers can respond

class BookingError extends Error {
  constructor(message) {
    super(message);
    this.name = this.constructor.name;
  }
}

class SeatTakenError extends BookingError {
  constructor(seat, byReference) {
    super(`Seat ${seat} is already booked on ${byReference}.`);
    this.seat = String(seat);
    this.byReference = byReference;
  }
}

class FullyBookedError extends BookingError {
  constructor(route) {
    super(`${route} is fully booked.`);
    this.route = route;
  }
}

class BookingClosedError extends BookingError {
  constructor(reference) {
    super(`${reference} is confirmed and can no longer change.`);
    this.reference = reference;
  }
}

// A caller can now branch on type and carry on, or rethrow what it does not know.
function attempt(action) {
  try {
    return { ok: true, value: action() };
  } catch (err) {
    if (err instanceof SeatTakenError) return { ok: false, retry: true, reason: err.message, seat: err.seat };
    if (err instanceof FullyBookedError) return { ok: false, retry: false, reason: err.message };
    if (err instanceof BookingError) return { ok: false, retry: false, reason: err.message };
    throw err;                      // not ours: let it travel
  }
}

console.log(attempt(() => 'booked D4'));
console.log(attempt(() => { throw new SeatTakenError('D4', 'BK-0001'); }));
console.log(attempt(() => { throw new FullyBookedError('Lagos - Abuja'); }));

// Every one is still a real Error, so generic handlers keep working.
const e = new SeatTakenError('D4', 'BK-0001');
console.log([e instanceof SeatTakenError, e instanceof BookingError, e instanceof Error]);
console.log(e.name);

Output


      

What this step teaches. A small error hierarchy, this.constructor.name for free naming, and errors that carry structured data.

Step 05

The trip, which owns the seat map

Two bookings must never hold the same seat. That rule cannot live in Booking, because a booking does not know about other bookings. It belongs to the trip.

Step 5: The trip, which owns the seat map

/* Carried over from steps 1 and 4, trimmed. */
class Seat {
  static ROWS = Object.freeze(['A', 'B', 'C', 'D']);
  static PER_ROW = 8;
  constructor(row, number) { this.row = row; this.number = number; Object.freeze(this); }
  get label() { return this.row + this.number; }
  toString() { return this.label; }
}

class BookingError extends Error {
  constructor(message) { super(message); this.name = this.constructor.name; }
}
class SeatTakenError extends BookingError {
  constructor(seat, by) { super(`Seat ${seat} is already booked on ${by}.`); this.seat = String(seat); this.byReference = by; }
}
class FullyBookedError extends BookingError {
  constructor(route) { super(`${route} is fully booked.`); this.route = route; }
}

/* [ new in this step ] */
class Trip {
  #route;
  #held = new Map();      // seat label -> booking reference
  #capacity;

  constructor(route, capacity = Seat.ROWS.length * Seat.PER_ROW) {
    this.#route = route;
    this.#capacity = capacity;
  }

  get route() { return this.#route; }
  get sold() { return this.#held.size; }
  get remaining() { return this.#capacity - this.#held.size; }
  get soldOut() { return this.remaining === 0; }

  heldBy(seat) { return this.#held.get(String(seat)) ?? null; }

  hold(seat, reference) {
    if (this.soldOut) throw new FullyBookedError(this.#route);
    const existing = this.heldBy(seat);
    if (existing) throw new SeatTakenError(seat, existing);
    this.#held.set(String(seat), reference);
    return this;
  }

  release(seat, reference) {
    if (this.heldBy(seat) !== reference) {
      throw new BookingError(`${seat} is not held by ${reference}.`);
    }
    this.#held.delete(String(seat));
    return this;
  }

  // A rendered seating plan, so the state is visible rather than described.
  plan() {
    const lines = [];
    for (const row of Seat.ROWS) {
      let line = row + '  ';
      for (let n = 1; n <= Seat.PER_ROW; n++) {
        line += this.#held.has(row + n) ? '[X]' : '[ ]';
      }
      lines.push(line);
    }
    return lines.join('\n');
  }

  toString() { return `${this.#route} — ${this.sold} sold, ${this.remaining} left`; }
}

const trip = new Trip('Lagos - Abuja', 8);

trip.hold(new Seat('A', 1), 'BK-0001');
trip.hold(new Seat('A', 2), 'BK-0001');
trip.hold(new Seat('D', 4), 'BK-0002');

console.log(String(trip));
console.log(trip.plan());

try { trip.hold(new Seat('A', 1), 'BK-0003'); }
catch (err) { console.log(err.name + ':', err.message, '| held by', err.byReference); }

trip.release(new Seat('A', 2), 'BK-0001');
console.log('after release:', String(trip));

Output


      

What this step teaches. Putting a rule in the object that can actually enforce it, plus a text rendering so state is inspectable.

Step 06

Events, so the rest of the system can listen

The ticket counter wants a receipt. The departures board wants a count. Neither belongs inside Trip, and Trip should not know they exist.

Step 6: Events, so the rest of the system can listen

class Emitter {
  #listeners = new Map();

  on(event, fn) {
    if (typeof fn !== 'function') throw new TypeError('A listener must be a function.');
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(fn);
    return () => this.off(event, fn);
  }

  off(event, fn) {
    this.#listeners.get(event)?.delete(fn);
    return this;
  }

  emit(event, payload) {
    // Copy first, so a listener that unsubscribes mid-emit is safe.
    for (const fn of [...(this.#listeners.get(event) ?? [])]) {
      try {
        fn(payload);
      } catch (err) {
        console.log('a listener threw and was ignored:', err.message);
      }
    }
    return this;
  }
}

class LiveTrip extends Emitter {
  #route; #held = new Map(); #capacity;

  constructor(route, capacity) {
    super();
    this.#route = route;
    this.#capacity = capacity;
  }

  get sold() { return this.#held.size; }
  get remaining() { return this.#capacity - this.#held.size; }

  hold(label, reference) {
    if (this.#held.has(label)) {
      this.emit('rejected', { label, reason: 'taken' });
      return this;
    }
    this.#held.set(label, reference);
    this.emit('held', { label, reference, remaining: this.remaining });
    if (this.remaining === 0) this.emit('soldout', { route: this.#route });
    return this;
  }
}

const trip = new LiveTrip('Lagos - Abuja', 3);

const stopReceipts = trip.on('held', d => console.log('receipt:', d.label, 'to', d.reference));
trip.on('held', d => console.log('board  :', d.remaining, 'seats left'));
trip.on('rejected', d => console.log('board  :', d.label, 'unavailable'));
trip.on('soldout', d => console.log('board  : FULLY BOOKED —', d.route));
trip.on('held', () => { throw new Error('a badly written listener'); });

trip.hold('A1', 'BK-0001');
stopReceipts();
trip.hold('A1', 'BK-0002');
trip.hold('A2', 'BK-0002');
trip.hold('A3', 'BK-0003');

Output


      

What this step teaches. Observer, an unsubscribe closure, and defensive iteration so one bad listener cannot break the emit.

Step 07

A store behind a small interface

Persistence is the part most likely to be replaced. Write the rest of the system against three methods, and the storage underneath becomes swappable at no cost.

Step 7: A store behind a small interface

// The interface every store implements: save, load, all.

class MemoryStore {
  #data = new Map();
  save(key, record) { this.#data.set(key, structuredClone(record)); return this; }
  load(key) { const r = this.#data.get(key); return r ? structuredClone(r) : null; }
  all() { return [...this.#data.values()].map(r => structuredClone(r)); }
}

class NamespacedStore {
  #inner; #prefix; #writes = 0;
  constructor(inner, prefix) { this.#inner = inner; this.#prefix = prefix; }
  save(key, record) { this.#writes++; this.#inner.save(this.#prefix + key, record); return this; }
  load(key) { return this.#inner.load(this.#prefix + key); }
  all() { return this.#inner.all(); }
  get writes() { return this.#writes; }
}

// A repository: the only class that knows how a Booking becomes a record.
class BookingRepository {
  #store;
  constructor(store) { this.#store = store; }

  save(booking) {
    this.#store.save(booking.reference, booking.toJSON());
    return this;
  }

  find(reference) {
    return this.#store.load(reference);
  }

  confirmedTotalNaira() {
    return this.#store.all()
      .filter(r => r.confirmed)
      .reduce((n, r) => n + r.totalNaira, 0);
  }
}

// A stand-in for the real Booking, so this step runs on its own.
const fakeBooking = (reference, seats, confirmed, totalNaira) => ({
  reference,
  toJSON: () => ({ reference, seats, confirmed, totalNaira }),
});

const store = new NamespacedStore(new MemoryStore(), 'park.');
const repo = new BookingRepository(store);

repo.save(fakeBooking('BK-0001', ['A1', 'A2'], true, 3750));
repo.save(fakeBooking('BK-0002', ['D4'], false, 1350));

console.log(repo.find('BK-0001'));
console.log('confirmed revenue, naira:', repo.confirmedTotalNaira());
console.log('writes counted by the wrapper:', store.writes);
console.log('missing record:', repo.find('BK-9999'));

Output


      

What this step teaches. Adapter, Repository, and a decorator that adds counting without the inner store knowing.

Step 08

The whole thing, assembled

Every part from the previous seven steps, wired together and run. This is the finished project; read it as the answer key and then change something and run it again.

Step 8: The whole thing, assembled

/* [ errors ] */
class BookingError extends Error {
  constructor(message) { super(message); this.name = this.constructor.name; }
}
class SeatTakenError extends BookingError {
  constructor(seat, by) { super(`Seat ${seat} is already booked on ${by}.`); this.seat = String(seat); this.by = by; }
}
class FullyBookedError extends BookingError {
  constructor(route) { super(`${route} is fully booked.`); this.route = route; }
}
class BookingClosedError extends BookingError {
  constructor(ref) { super(`${ref} is confirmed and can no longer change.`); this.reference = ref; }
}

/* [ value object ] */
class Seat {
  static ROWS = Object.freeze(['A', 'B', 'C', 'D']);
  static PER_ROW = 8;

  constructor(row, number) {
    if (!Seat.ROWS.includes(row)) throw new RangeError(`No row "${row}".`);
    if (!Number.isInteger(number) || number < 1 || number > Seat.PER_ROW) {
      throw new RangeError(`Seat number must be 1 to ${Seat.PER_ROW}.`);
    }
    this.row = row; this.number = number;
    Object.freeze(this);
  }
  get label() { return this.row + this.number; }
  get tier() { return this.row === 'A' || this.row === 'B' ? 'luxury' : 'economy'; }
  toString() { return this.label; }
  static parse(text) {
    const m = /^([A-Da-d])(\d{1,2})$/.exec(String(text).trim());
    if (!m) throw new SyntaxError(`Cannot read "${text}" as a seat.`);
    return new Seat(m[1].toUpperCase(), Number(m[2]));
  }
}

/* [ pricing (strategy) ] */
class PriceList {
  #base; #fee;
  constructor({ luxury, economy, fee }) { this.#base = { luxury, economy }; this.#fee = fee; }
  seatNaira(seat) { return this.#base[seat.tier]; }
  get feeNaira() { return this.#fee; }
}
const FULL = { label: 'full price', apply: p => p };
const STUDENT = { label: 'student', apply: p => Math.round(p * 0.8) };

class Pricing {
  #list; #discount;
  constructor(list, discount = FULL) { this.#list = list; this.#discount = discount; }
  with(discount) { return new Pricing(this.#list, discount); }
  get label() { return this.#discount.label; }
  quote(seats) {
    if (!seats.length) return { subtotal: 0, fee: 0, total: 0 };
    const subtotal = seats.reduce((n, s) => n + this.#discount.apply(this.#list.seatNaira(s)), 0);
    return { subtotal, fee: this.#list.feeNaira, total: subtotal + this.#list.feeNaira };
  }
}

/* [ observer ] */
class Emitter {
  #listeners = new Map();
  on(event, fn) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(fn);
    return () => this.off(event, fn);
  }
  off(event, fn) { this.#listeners.get(event)?.delete(fn); return this; }
  emit(event, payload) {
    for (const fn of [...(this.#listeners.get(event) ?? [])]) {
      try { fn(payload); } catch (err) { console.log('listener threw:', err.message); }
    }
    return this;
  }
}

/* [ the trip owns the seat map ] */
class Trip extends Emitter {
  #route; #held = new Map(); #capacity;
  constructor(route, capacity = Seat.ROWS.length * Seat.PER_ROW) {
    super(); this.#route = route; this.#capacity = capacity;
  }
  get route() { return this.#route; }
  get sold() { return this.#held.size; }
  get remaining() { return this.#capacity - this.#held.size; }
  get soldOut() { return this.remaining === 0; }
  heldBy(seat) { return this.#held.get(String(seat)) ?? null; }

  hold(seat, reference) {
    if (this.soldOut) throw new FullyBookedError(this.#route);
    const by = this.heldBy(seat);
    if (by) throw new SeatTakenError(seat, by);
    this.#held.set(String(seat), reference);
    this.emit('held', { seat: String(seat), reference, remaining: this.remaining });
    if (this.soldOut) this.emit('soldout', { route: this.#route });
    return this;
  }

  plan() {
    return Seat.ROWS.map(row => {
      let line = row + '  ';
      for (let n = 1; n <= Seat.PER_ROW; n++) line += this.#held.has(row + n) ? '[X]' : '[ ]';
      return line;
    }).join('\n');
  }
}

/* [ booking ] */
class Booking {
  static #next = 1;
  #reference; #seats = new Map(); #confirmed = false; #pricing; #trip;

  constructor(trip, pricing) {
    this.#trip = trip;
    this.#pricing = pricing;
    this.#reference = 'BK-' + String(Booking.#next++).padStart(4, '0');
  }

  get reference() { return this.#reference; }
  get seats() { return [...this.#seats.values()]; }
  get count() { return this.#seats.size; }
  get confirmed() { return this.#confirmed; }
  get quote() { return this.#pricing.quote(this.seats); }
  get total() { return `₦${this.quote.total}`; }

  add(seatOrLabel) {
    if (this.#confirmed) throw new BookingClosedError(this.#reference);
    const seat = seatOrLabel instanceof Seat ? seatOrLabel : Seat.parse(seatOrLabel);
    this.#trip.hold(seat, this.#reference);   // may throw; nothing is half-done
    this.#seats.set(seat.label, seat);
    return this;
  }

  using(discount) {
    if (this.#confirmed) throw new BookingClosedError(this.#reference);
    this.#pricing = this.#pricing.with(discount);
    return this;
  }

  confirm() {
    if (this.#confirmed) throw new BookingClosedError(this.#reference);
    if (!this.#seats.size) throw new BookingError('Cannot confirm an empty booking.');
    this.#confirmed = true;
    return this;
  }

  toString() { return `${this.#reference}  ${this.seats.join(' ') || '(empty)'}  ${this.total}`; }
  toJSON() {
    return { reference: this.#reference, seats: this.seats.map(String), confirmed: this.#confirmed, totalNaira: this.quote.total };
  }
}

/* [ storage (adapter + repository) ] */
class MemoryStore {
  #data = new Map();
  save(key, record) { this.#data.set(key, structuredClone(record)); return this; }
  load(key) { const r = this.#data.get(key); return r ? structuredClone(r) : null; }
  all() { return [...this.#data.values()].map(r => structuredClone(r)); }
}
class BookingRepository {
  #store;
  constructor(store) { this.#store = store; }
  save(booking) { this.#store.save(booking.reference, booking.toJSON()); return this; }
  find(ref) { return this.#store.load(ref); }
  revenueNaira() { return this.#store.all().filter(r => r.confirmed).reduce((n, r) => n + r.totalNaira, 0); }
}

/* [ run it ] */
const trip = new Trip('Lagos - Abuja', 8);
const pricing = new Pricing(new PriceList({ luxury: 18000, economy: 12000, fee: 200 }));
const repo = new BookingRepository(new MemoryStore());

trip.on('held', d => console.log(`  held ${d.seat} for ${d.reference}, ${d.remaining} left`));
trip.on('soldout', d => console.log(`  FULLY BOOKED: ${d.route}`));

console.log('[ booking one, two luxury seats, student rate ]');
const one = new Booking(trip, pricing).add('A1').add('A2').using(STUDENT).confirm();
console.log(String(one));
repo.save(one);

console.log('[ booking two, a economy seat, full price ]');
const two = new Booking(trip, pricing).add(Seat.parse('d4')).confirm();
console.log(String(two));
repo.save(two);

console.log('[ booking three tries a seat that is gone ]');
const three = new Booking(trip, pricing);
try {
  three.add('A1');
} catch (err) {
  console.log(`  ${err.name}: ${err.message}`);
  three.add('B5');
}
console.log(String(three.confirm()));
repo.save(three);

console.log('[ the house ]');
console.log(trip.plan());
console.log(`  ${trip.sold} sold, ${trip.remaining} left`);
console.log('  confirmed revenue, naira:', repo.revenueNaira());
console.log('  stored record:', JSON.stringify(repo.find('BK-0001')));

console.log('[ a confirmed booking refuses to change ]');
try { one.add('C1'); } catch (err) { console.log(`  ${err.name}: ${err.message}`); }

Output


      

What this step teaches. Every idea in the course working together: value objects, encapsulation, static id issuing, inheritance for the emitter and the error hierarchy, polymorphic discounts, composition throughout, and typed failures.

Next

Take it further

Eight extensions, roughly in order of difficulty. Each one should be possible without rewriting what is already there, and if one of them forces a rewrite, that is worth understanding: it means a boundary was drawn in the wrong place.

  1. Add a Luggage line item so a booking can hold things that are not seats. The total should not need a switch.
  2. Add a wheelchair space that occupies two adjacent seats and cannot be split.
  3. Add a Refund, which reopens a confirmed booking exactly once and releases its seats.
  4. Add a second departure on the same route, sharing the price list but not the seat map.
  5. Add a hold that expires: a seat reserved for fifteen minutes, then released. Inject the clock so you can test it without waiting.
  6. Add a LocalStorageStore implementing the same three methods, and swap it in without editing BookingRepository.
  7. Add a seating plan that marks your own booking differently from other people’s.
  8. Write assertions for every rule in the brief, as a plain function that logs pass or fail. Then break a rule on purpose and watch the right assertion catch it.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.