Counterfoil
Confidential
2024
Executive Summary
Counterfoil is a multi-tenant booking platform. Operators — galleries, turfs, studios, cruises, cinemas — use it to define what they sell, take reservations, manage capacity, and get paid.
The premise is easy to state and hard to build: one engine should model any kind of booking. Not a booking product with a plugin system, and not a product per vertical. One engine, where supporting a new kind of booking is configuration rather than a software release.
We did not start there. We arrived at it by building the wrong thing four times.
This document describes how a pile of bespoke, per-customer configurations became a generic engine; why we chose to rewrite a working system rather than clean it up; and what that abstraction cost us — because it did cost us.
How We Got Here
The engine was not designed. It was cornered into existence, one customer at a time.
An art gallery. The first booking type had no time dimension at all. The gallery sold entry as a retail product — a ticket type, variants at different prices, no slots, no schedule. We called the configuration ticket. It worked, and it modelled a real class of business: anywhere that sells a single admission to a whole event.
A turf. Fixed timeslots — 10–11, 11–12 — with capacity per slot. Nothing about the ticket configuration helped. So we built a second one, booking, and shipped it.
Two configurations. Two code paths. Both fine.
Then the requests started arriving, and each one was a business we wanted:
"I don't have fixed timeslots. People come in any time and book anywhere from one to three hours."
"I don't have timeslots at all. People come any time during operating hours — but I want them to book a specific day so I can anticipate traffic."
"We run a cruise, twice a day, every day, and customers pick their seat."
"We have a cinema. Different films, different dates, different times, different prices per seat row, across multiple halls."
Every one of these was a new configuration, and not one of them was reusable. The free-form court had nothing to give the day-scoped venue. The cruise's seat picker had nothing to give the cinema's per-row pricing, even though both involve choosing a named seat. We were building a bespoke booking product per customer, calling it configuration, and shipping it inside one codebase.
The realisation was not subtle: we were not going to win by adding a fifth configuration. We had to decompose these businesses into reusable primitives — a small set of independent dimensions from which every one of these scenarios, and ones we had not met yet, could be assembled.
The Decision to Rewrite
The legacy system was a Django monolith. Its problem was not performance and it was not scale — traffic was, and remains, modest. Its problem was coupling: the code was tangled, and small bug fixes regressed major features. A change to a booking path could surface as a failure somewhere nobody was looking.
We rewrote, and we split the monolith into services, each owned by a specific engineer on a team that had lived with the product long enough to know what it actually did.
Why a rewrite and not a cleanup
The case for cleaning up in place is strong and it was argued seriously. A disciplined refactor of the existing code would prevent the next breakdown without paying for a rewrite, and rewrites are famous for failing.
They fail for a specific reason, though: the people doing them do not understand what the old system does. That was not our situation. Every engineer carried years of accumulated context on the product and the domain — which is the scarce ingredient a rewrite actually consumes. User volume was low, so the blast radius of being wrong was small. Those two facts are what made the risk acceptable, and neither of them is transferable advice. A team without that context should clean up in place.
What actually fixed the coupling
This is the part worth being honest about, because it is easy to tell it the flattering way.
The regressions largely stopped. But when I list what changed, the list is: more tests, clearer code splitting, and monitoring across services. None of those three required microservices. All of them are available inside a Django monolith to anyone willing to do the work.
What we actually got was a rewrite — the chance to build the thing properly with domain knowledge we did not have the first time. The service boundaries came with it, and their real contribution was organisational: each engineer owns a piece, and the network makes those boundaries impossible to accidentally reach across in a way that code review never quite managed.
The rewrite fixed the coupling. The services divided the work. Those are two different claims, and conflating them would flatter the architecture at the expense of the truth.
The Booking Engine: Configured, Not Coded
Every booking type is described, not programmed. An operator authors a configuration along a few independent dimensions, and one engine serves it.
| Dimension | What the operator configures |
|---|---|
| Schedule | Free-form time ranges (increments, min/max duration, lead times, operating hours) or fixed, pre-published slots |
| Resources & seating | What a booking consumes — a count of interchangeable units, or specific named seats, including multi-person units where one "seat" holds a party |
| Pricing | Flat, hourly, tiered, or donation, plus overrides by day of week, time window, date range, or specific seat |
| Access | Passes and memberships with usage limits, validity windows, day restrictions, expiry, transferability, re-scan rules |
| Capacity | Per-day admission ceilings, independent of physical size |
| Scope | Every configuration belongs to a location and is gated to the right staff |
Because the dimensions are independent, the same engine produces products that feel unrelated to the operator:
| Scenario | Schedule | Pricing | Resource model |
|---|---|---|---|
| Art gallery entry | None — retail admission | Per ticket variant | Daily admission ceiling |
| Tennis court | Hourly, free-form | Hourly | Pool of interchangeable courts |
| Yoga class | Fixed weekly slots | Tiered (Regular / Member) | Named mats |
| Bowling party | Free-form | Flat per lane | Lane seating a multi-person party |
| Cruise | Fixed, twice daily | Per seat | Named seats |
| Cinema | Fixed, per screening | Per seat row | Named seats across multiple halls |
| Gym membership | None — a pass, not a slot | Per pass | Unlimited use, time-limited |

The Hard Part: One Availability Function
The dimensions above took work. The schedule primitive took the longest, because of what it implied: a single availability function that serves every configuration.
That function is the centre of the system.
availability(config, dateRange) -> [ slot, remaining_capacity ]
Two arguments in. Slots with remaining capacity out. Every booking type in the product goes through it.
The unification rests on one decision: everything discretises into increment-sized slots, and a booking is a contiguous run of them.
- A free-form court that takes bookings of one to three hours is not an infinite space of intervals. It is 30-minute slots, and a two-hour booking is four contiguous slots that must all be free.
- An art gallery with no time dimension is one slot per day, spanning operating hours.
- A cinema screening is one slot, at a fixed time, per hall.
- A named seat — a cruise deck chair, a cinema seat, a yoga mat — is not a separate concept. It is a resource with capacity one.
- A day-scoped booking with no timeslot is a single slot covering the operating day, capacity-limited by the daily admission ceiling.
Once slots and capacity are the universal shape, availability stops being six algorithms and becomes one. Pricing is then a separate pass over the same result, which is what lets the cinema vary price by seat row without the availability logic knowing anything about rows.
Where it gets ugly
Two places, both consequences of the same decision.
Fine increments explode the slot count. A venue with five-minute increments, twelve operating hours, forty resources, and a ninety-day lookahead is a very large number of slots to compute and hold. The increment is an operator-facing configuration field, which means an operator can make the engine expensive by filling in a form.
Contiguous runs are not a single-row check. "Is this slot free" is trivial. "Are these four consecutive slots free on the same resource" is a different query, and it is the one that runs on every availability request for every free-form venue. A count of interchangeable units makes it harder still, because the run must be contiguous per unit, not in aggregate.
What the Abstraction Costs
Every configuration engine makes something hard that would have been trivial in code. Ours took the bill in one place: validating that a configuration is coherent.
When booking types were code, the type system and the code review caught nonsense. A TennisCourtBooking could not accidentally be donation-priced across named seats with a recurring pass, because nobody would write that class. Now an operator composes six independent dimensions in a form, and the engine must decide whether the composition means anything.
Donation pricing on assigned cinema seats. A recurring membership pass against a schedule with no time dimension. A minimum duration longer than the operating day. A per-seat price override naming a seat that the resource model does not contain. These are all authorable. Most are meaningless.
This is the tax for "supporting a new booking type is data entry, not a release." We accepted it deliberately. It is still the thing most likely to produce a support ticket that reads like a bug and is actually a configuration.
Reach: One Platform, Every Channel
The same availability is reachable from every channel an operator sells through — an operator dashboard, customer apps, partner integrations, and public storefronts. Every channel reads and writes the same availability, so capacity stays consistent regardless of where a booking originates, and adding a channel does not mean reimplementing the booking rules.
Deployment Architecture
A note on where the numbers come from. The 99.99% uptime figure belongs to the legacy Django monolith that has served operators to date. The architecture below describes the platform that replaces it. Carrying that uptime across is the objective, not a result I am claiming in advance.
The new platform's services run as containers on managed Kubernetes (AWS EKS). The full cloud footprint — networking, cluster, data stores, registries — is provisioned with Terraform, so each environment is reproducible and reviewable as code rather than hand-built. Delivery is GitOps: ArgoCD continuously reconciles the cluster against declarative Helm manifests held in the repository, so running state always matches version control and a rollback is a revert. CI builds and tests on every change and publishes images to a private registry.
Stateful concerns are delegated to managed services: PostgreSQL as the durable source of truth, a managed message broker for event-driven coordination between services, and a managed email service for customer notifications. Workloads obtain cloud permissions through IAM-bound service accounts (IRSA) — no static cloud keys are baked into images or stored in the cluster. The system is instrumented with OpenTelemetry traces and metrics.
| Layer | Choice | Purpose |
|---|---|---|
| Orchestration | Managed Kubernetes (AWS EKS) | Run and scale the containerised services |
| Provisioning | Terraform | Reproducible, reviewable infrastructure per environment |
| Delivery | GitOps (ArgoCD + Helm) | Declarative releases continuously synced from the repo |
| Build | CI + private container registry | Build, test, publish images on every change |
| Data | Managed PostgreSQL | Durable, transactional source of truth |
| Messaging | Managed message broker | Event-driven coordination between services |
| Managed email service | Outbound customer notifications | |
| Credentials | IAM-bound service accounts (IRSA) | Keyless cloud access — no stored secrets |
| Environments | Isolated dev / staging / prod | Separate networks; promote upward before production |
| Observability | OpenTelemetry + monitoring backend | Tracing and metrics across the platform |

Replacing a System That Works
The new platform is provisioned alongside the legacy system, sharing no resources — separate networks, separate data stores, nothing borrowed. Changes are proven in isolated development and staging environments before they are promoted.
Replacing software that already works is a different discipline from building it. The legacy monolith is not a failure; it is a system that served real businesses reliably for years and taught us what the product actually was. Everything in the rewrite — the six configuration dimensions, the single availability function — is knowledge that only exists because the first version was built and lived with.
That is the argument for a rewrite over a cleanup, and it is also the argument for humility about one. You do not get to keep the reliability of the old system for free. You have to earn it again.
Domain and Feature Summary
| Area | Description |
|---|---|
| Onboarding & access | Sign up, verify, create an organisation and its first location; role-based access for owners, managers, staff |
| Organisation & location management | Many organisations, each with many locations, under clear ownership |
| Configuration authoring | Define booking types across schedule, pricing, resources, access; visual seat-map layout |
| Availability & holds | Real-time availability, short-lived holds during checkout |
| Bookings | Simple and multi-person bookings; view, manage, cancel |
| Passes & memberships | Issue, redeem, void, reissue; session packages |
| Pricing | Flat, hourly, tiered, donation, with day/time/date/seat overrides |
| Notifications | Dependable confirmation and cancellation messages |
| Multi-channel selling | One source of truth across dashboard, apps, partner integrations, storefronts |
Out of scope (current phase): multi-factor auth and SSO, self-service password reset, SMS and push notifications, and built-in billing / point-of-sale.
Summary
Counterfoil began as a booking product that grew a new bespoke configuration for every customer it met — a gallery, a turf, a court, a cruise, a cinema — until it was obvious that the fifth one would not be the last.
The engine that replaced it rests on one idea: describe a booking along independent dimensions, discretise every schedule into increment-sized slots, treat a named seat as a resource of capacity one, and availability becomes a single function of a configuration and a date range. Supporting a new kind of booking becomes data entry rather than a release.
That abstraction is not free. It moves the difficulty from writing code to validating configurations, and it lets an operator make the engine expensive by choosing a small increment.
The rewrite is what fixed the coupling; the service boundaries are how the team divides the work. Keeping those two claims apart is the only way the rest of this document is worth reading.