Back to engineering

Engineering

One dashboard, seven industries: a config-driven, vertical-aware admin

The Bookatu engineering team7 min read

Bookatu runs salons, spas, trainers, coaches, studios, restaurants and photographers from one admin. Here is how a single config map, plus named constants instead of hardcoded industry strings, keeps that admin clean as the list of verticals grows.

The short version

  • Bookatu serves seven verticals (salon, spa, personal trainer, sports coach, fitness/yoga studio, restaurant, photographer) from one admin, so the UI has to bend per industry.
  • We do that bending with a single config map keyed by vertical, not with if/else chains scattered across the dashboard and nav.
  • Each vertical is a named constant, never a raw string typed inline. The compiler, not a code reviewer, catches the typos.
  • A TypeScript Record forces every vertical to have a full config, so adding the eighth industry is mostly a data change.
  • The hard parts (photographer galleries, trainer session packs, restaurant floor plans) are turned on with feature flags inside that same config.

The problem: one admin, seven industries

A salon owner and a wedding photographer want very different dashboards. The salon thinks in appointments, staff and walk-ins. The photographer thinks in shoots, private galleries and photo delivery. A restaurant thinks in tables and covers. A personal trainer thinks in sessions and packs. Bookatu runs all of them on one platform, with 0% booking commission and payouts going straight to each tenant's own bank through Stripe Connect. That shared core is the whole point. But a shared core still has to show the right words and the right tools to each kind of business.

The naive way to do this is to branch. You ask, in the navigation, "is this a photographer? then show Galleries." You ask, in the dashboard, "is this a restaurant? then show the floor plan." You ask, on the booking screen, "is this a trainer? then call it a session, not an appointment." Each question is one line. Seven verticals and a dozen screens later, those lines are everywhere, and they never agree on the spelling of the industry name.

Why if/else spaghetti rots

Branching on type is the classic case the Open/Closed Principle was written to kill. Software should be open for extension but closed for modification. A long if/else or switch on the industry breaks that rule, because adding a new vertical means editing every place that branches. The refactoring literature is blunt about this: replacing sprawling conditionals with a lookup is one of the highest-leverage cleanups you can do, precisely because it moves the variation into data instead of control flow.

There is a second, quieter problem. The branches usually compare against a hardcoded string like "photographer". That string is a magic value. It has no compile-time safety, a typo such as "photgrapher" fails silently, and a rename means hunting down every occurrence by grep and hope. Replacing magic strings with named constants or enums is a small change that removes a whole class of bugs.

The config map

Instead of asking what the vertical is at each call site, we describe each vertical once, in one file, and let the screens read from that description. This is config-driven UI: the layout and behaviour are looked up from a configuration object rather than written into the components. Start by giving every vertical a name and a shape.

ts
// verticals.ts
export const VERTICAL = {
  Salon: "salon",
  Spa: "spa",
  PersonalTrainer: "personal_trainer",
  SportsCoach: "sports_coach",
  Studio: "studio",
  Restaurant: "restaurant",
  Photographer: "photographer",
} as const;

// One source of truth for the union of valid verticals.
export type Vertical = (typeof VERTICAL)[keyof typeof VERTICAL];

type NavItem = { key: string; label: string; icon: string };

type VerticalConfig = {
  bookingNoun: string;   // "appointment" | "session" | "reservation" | "shoot"
  clientNoun: string;    // "client" | "guest" | "athlete"
  nav: NavItem[];
  features: {
    galleries: boolean;
    sessionPacks: boolean;
    floorPlan: boolean;
    missedPaymentBoard: boolean;
  };
};

Now the map itself. Because it is typed as Record<Vertical, VerticalConfig>, TypeScript refuses to compile if any vertical is missing a config. That is the exhaustiveness guarantee doing the work for you: the compiler, not a reviewer, makes sure you handled every case.

ts
export const VERTICAL_CONFIG: Record<Vertical, VerticalConfig> = {
  [VERTICAL.Photographer]: {
    bookingNoun: "shoot",
    clientNoun: "client",
    nav: [CALENDAR, CLIENTS, GALLERIES, DELIVERY, PAYMENTS],
    features: { galleries: true, sessionPacks: false,
                floorPlan: false, missedPaymentBoard: true },
  },
  [VERTICAL.Restaurant]: {
    bookingNoun: "reservation",
    clientNoun: "guest",
    nav: [CALENDAR, TABLES, GUESTS, PAYMENTS],
    features: { galleries: false, sessionPacks: false,
                floorPlan: true, missedPaymentBoard: false },
  },
  [VERTICAL.PersonalTrainer]: {
    bookingNoun: "session",
    clientNoun: "athlete",
    nav: [CALENDAR, CLIENTS, PACKS, PAYMENTS],
    features: { galleries: false, sessionPacks: true,
                floorPlan: false, missedPaymentBoard: true },
  },
  // ...salon, spa, sports coach, studio
};

// Every screen reads the config. None of them branch on the string.
export function configFor(v: Vertical): VerticalConfig {
  return VERTICAL_CONFIG[v];
}

The navigation component now renders configFor(org.vertical).nav. The booking screen labels its button with configFor(org.vertical).bookingNoun. The dashboard shows the floor plan only when features.floorPlan is true. There is no industry comparison left in any component. The components became dumb, which is the goal.

Named constants beat hardcoded strings

Notice that the word "photographer" appears exactly once, inside VERTICAL. Everywhere else, code refers to VERTICAL.Photographer. That single move buys a lot:

  • Autocomplete lists the valid verticals, so nobody guesses the spelling.
  • A typo like VERTICAL.Photgrapher is a compile error, not a blank screen in production.
  • Renaming the underlying value is a one-line change in the constant.
  • Find-usages actually finds every usage, because they all flow through one symbol.
  • The Vertical union type can be reused for database columns, API payloads and analytics, so the same seven names are honoured end to end.
If you need to grep for a string to change a behaviour, the behaviour was in the wrong place. Put the variation in data and let the type system carry it.

Adding a vertical becomes a data change

When we add the next industry, the steps are small and the compiler leads. You add one member to VERTICAL. Immediately the Record<Vertical, VerticalConfig> type fails, because the new vertical has no entry. You add the entry, fill in its nouns, nav and feature flags, and the build goes green. No component changes. This is the closed-for-modification half of Open/Closed in practice: the call sites never move, only the configuration grows.

The same pattern lets each vertical own its hard features behind a flag rather than a branch. The photographer config turns on galleries, which is what drives the private per-client gallery flow: the studio uploads high-res, the platform auto-generates a low-res, 1080px, EXIF-stripped preview with the watermark baked in, and clients pick favourites against an included free count before paying to unlock extra photos at full resolution, all shared through long-lived magic links. The trainer and coach configs turn on session packs, so buying ten sessions auto-decrements, and the missed-payment board where a tenant logs cash or bank and chases what they are owed. The restaurant config turns on the floor-plan editor, with tables sized by seats. Each of these is a feature line in one config, not a special case smeared across the admin.

Why this holds up

The payoff is that the admin stays honest as the product widens. New engineers read one file and understand how the seven industries differ. Reviewers check a config diff, not seven scattered conditionals. The client portal, the per-tenant branding and dark mode, and the booking flow all sit on the same core, and the only thing that changes per vertical is the data they read. Config-driven UI plus named constants is not a clever trick. It is just keeping the variation in one place the compiler can see, so the dashboard can serve a salon and a wedding photographer without growing a tangle in the middle.

Sources

  • Config-Driven UI (Piyali Das): https://medium.com/@piyalidas.it/config-driven-ui-c155f908b798
  • Open, closed principle (Wikipedia): https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle
  • Discriminated Unions and Exhaustiveness Checking in TypeScript (FullStory): https://www.fullstory.com/blog/discriminated-unions-and-exhaustiveness-checking-in-typescript/
  • Refactoring with Polymorphism: Say Goodbye to Complex Conditionals (DEV Community): https://dev.to/pathus90/refactoring-with-polymorphism-say-goodbye-to-complex-conditionals-50o9
  • TypeScript Handbook: Everyday Types (Union Types): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
architecturetypescriptconfig-driven-uimaintainabilitymulti-tenantdesign-patterns

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs