Domains and bolt-ons

The three domains the site is composed of, which domain owns each content type, and how route packages are mounted.

2026-11-07

The domain model

The site is sliced into domains: each is a part of the site with its own content types, components and route package. This site is composed of three — content, video and basketball — plus core, which every page needs.

One boundary is drawn before any mechanism: core + content are not optional. The chrome, articles and editorial pages are the baseline, present unconditionally. video and basketball are bolt-ons: each adds its own content types, components and routes on top.

The ownership map

packages/model/domains.json is the single source of truth for what a domain is. It has two halves:

  • domains — every domain and its tier.
  • types — the type → domain ownership map: every content type has exactly one owning domain. lint-migrations.mjs enforces it (a domain's migrations create and delete only types it owns).

The declaration order of domains is the canonical composition order — migrations, schema and codegen all apply domains in that order.

The file is shared with the template, so it also declares the template's other domains (football, sport-common, gamification, retail, hospitality, ads, app). None of them is composed here: their template blocks are empty, and no package, migration or route of theirs is in this repo.

The tiers, as this site uses them

Tier Domains here Behaviour
baseline core, content In every composition, undeclared. Requesting them is harmless; omitting them is impossible.
bolt-on video, basketball Declared in club.config.ts's domains list and composed in by pnpm compose.
implied — (sport-common, not composed) Joins automatically when a domain that requires it is present. Nothing here requires it.
quarantine quarantine Dead/test types carried by the baseline; never composed, left to rot by ruling.

The three domains

  • content owns the editorial types — articles, content pages, news listings, the home page and the content blocks — rendered by @v4/content-app with @v4/ghost-content. Its model slice is migrations/content/ and src/content/.
  • video owns the video authoring types (Video Landing Page, collections, playlists, rails, live streams, YouTube items) — migrations/video/ and src/video/ — rendered by @v4/video-app with @v4/ghost-video. Most of what it shows comes from the video platform through @v4/media, not from Contentful. See Media and video.
  • basketball is this project's own domain: the competition data areas (teams, players, coaches, games with the Game Centre, standings, stats). It has no content types of its own and requires nothing — the sports types the video platform and the data pages read (competition, season, team, fixture, player, venue) are this site's own migrations in migrations/club/. Its components are @v4/ghost-basketball (built from ELB – Web – Basketball), its routes @v4/basketball-app and its data @v4/competition-engine. It shares nothing with the template's football domain. See Basketball and the Competition Engine.

What a bolt-on contributes

A bolt-on contributes across five planes, each extending a mechanism that already exists rather than inventing one:

Plane Contribution
Components @v4/ghost-<domain>, built from its own Figma file
Model slice migrations/<domain>/ plus its fragments and transforms under src/<domain>/
Routes Per-route factories exported from a *-app package, listed in its routes.json
Config schema A config section (where the domain has a runtime integration) with cross-gates
Dictionary + chrome The domain's dictionary keys; declared nav items and search sources as data

Basketball's model plane is the exception above: its types live in migrations/club/.

Composition

The site's composition is the ordered set of domains its environment applies: the baseline (core + content, always) plus the bolt-on domains derived from the config's domains list, plus any implied domains their dependents pull in. packages/model/composition.json records this per environment, and pnpm compose regenerates it from club.config.ts.

composition.json

This repo's file:

{
  "$comment": "…",
  "apps": {
    "web": ["content", "video", "basketball"],
  },
  "domains": ["content", "video", "basketball"],
}

apps maps the app (apps/web, the only one) to its bolt-ons; domains is the resolved list in canonical order. Baseline domains need not be listed — content appears here because club.config.ts lists it, and the resolver adds the baseline either way. (The file's $comment still describes the template's own composition; it is stale text, not configuration.)

The resolver — one definition of "the composition"

packages/model/scripts/composition.mjs exports resolveComposition(spec, requested), a pure function consumed by the migration runner, the lint and compose-club alike — so there is exactly one definition of which domains apply and in what order. Its rules:

  • Baseline domains are added to every composition, whether or not requested.
  • Implied domains join automatically when a domain that requires them is present; requesting one directly throws.
  • Quarantine domains are refused; an unknown name throws.
  • The result is the domains.json declaration order filtered to the included set — never the request order.

Any of these violations fails loudly at compose time rather than producing a subtly wrong environment.

pnpm compose — the generator

pnpm compose (an alias for pnpm --filter @v4/template-config compose-club --) reads the app's club.config.ts and materialises its composition:

  1. composition.json — the composed bolt-ons, validated through the model's resolver (so unknown/quarantined/implied names fail here) and written in canonical order.
  2. Route shims — one generated page.tsx for every route a composed domain's *-app package registers, under apps/web/src/app/[locale]/ (see Bolt-on route packages).
  3. Boundary layouts — one layout.tsx per segment a route names as its "boundary" (e.g. app/[locale]/news/layout.tsx).
  4. Generated lib files — apps/web/src/lib/locale-routing.generated.ts (the locale segments, from locales) and apps/web/src/lib/sections.generated.ts (the default competition and each section's capabilities, from sections).

It is deterministic by construction: same config in, same bytes out.

Drift-gated, like tokens and model

pnpm compose -- --check regenerates in memory and diffs against disk, printing a CI error for every stale, missing or orphaned artefact and exiting non-zero. This is the tokens/model drift gate applied to the app layer, and it runs inside pnpm verify before every push and in CI. A route shim marked GENERATED whose route is no longer composed is pruned; hand-written pages (no generated header) are never touched.

Bolt-on route packages

Next.js routes are files on disk, so a composed app needs files to exist. A bolt-on domain's routes live in a *-app package that exports per-route factories, and compose-club writes the thin shim files that wire each factory into the app. @v4/content-app carries the baseline content routes, @v4/video-app the video domain's, and @v4/basketball-app this project's own basketball domain's — all three built the same way.

Factories, not plain re-exports

A route package does not export page components directly — it exports factory functions that take the app's injected dependencies and return a Page plus generateMetadata:

// packages/video-app/src/video-landing/index.tsx
export function createVideoLandingRoute({
  media, // the site's MediaConfig, built from club.config
  client, // the site's model client
  locale, // the site's default locale
}: VideoLandingRouteConfig): VideoLandingRoute {
  /* … */
  return { Page, generateMetadata };
}

Dependency injection keeps the route packages free of the app: the factory names what it needs (client, locale, media), and the generated shim supplies the app's instances, built from club.config.ts.

routes.json — the route registry

Each route package carries a routes.json that compose-club reads:

{
  "domain": "video",
  "routes": [
    {
      "id": "video-landing",
      "segment": "video",
      "subpath": "./video-landing",
      "factory": "createVideoLandingRoute",
      "params": [],
      "deps": ["client", "media", "locale"],
    },
    {
      "id": "video-detail",
      "segment": "video/[idOrSlug]",
      "subpath": "./video-detail",
      "factory": "createVideoDetailRoute",
      "params": ["idOrSlug"],
      "deps": ["client", "media", "locale"],
    },
  ],
}

segment is the app-route path (with [param] segments); subpath is the package export the factory lives behind; deps names the injected dependencies. compose-club validates each entry against the package's own exports map and factory source — a subpath not in exports, or a declared [param] not in params, fails the compose. The content package uses the same registry for path-based pagination: a -paged route reuses its base factory (same subpath) and reads the optional page path segment. The other optional keys:

  • -address / -path routes — one catch-all after the slug (news/category/[category]/[...address], teams/[...path]) carries the filter pairs and/or page/<n>, read by the same factory.
  • boundary — the segment whose generated layout.tsx holds the route's <Suspense> boundary instead of the shim, so navigating between routes under it keeps the current page on screen ("news" for every News route).
  • loading — a package export used as the shim's Suspense fallback and the segment's loading.tsx.
  • staticParams — the shim re-exports the factory's generateStaticParams.
  • pageProps — the factory's Page takes Next's page props although the segment has no param of its own (a bare area address that reads its search params).

The generated shim

For each route, compose-club writes a committed, diffable page.tsx:

// apps/web/src/app/[locale]/video/[idOrSlug]/page.tsx
// GENERATED by compose-club — do not edit. route:video-detail (video domain, @v4/video-app)
// Regenerate with `pnpm compose`; drift fails CI.
import { createVideoDetailRoute } from "@v4/video-app/video-detail";
import { Suspense, type ReactNode } from "react";
import clubConfig from "../../../../../club.config";
import { contentClient } from "../../../../lib/content";
import { mediaSettings } from "../../../../lib/media";
import RouteLoading from "../../route-loading";

const route = createVideoDetailRoute({
  client: contentClient,
  media: mediaSettings,
  locale: clubConfig.locales.default,
});

export default function Page(props: Parameters<typeof route.Page>[0]): ReactNode {
  return (
    <Suspense fallback={<RouteLoading />}>
      <RoutePageBody {...props} />
    </Suspense>
  );
}

async function RoutePageBody(props: Parameters<typeof route.Page>[0]): Promise<ReactNode> {
  return route.Page(props);
}

export const generateMetadata = route.generateMetadata;

Three properties make this the house answer rather than a hack: it is committed and diffable (composing a domain in is a PR whose diff is the shims + one config line + the model composition change), deterministic (regenerated identically every time, drift-gated in CI), and tree-shaken by construction (a domain that is not composed has no shim, so its code is never imported: there is nothing to gate, guard or accidentally ship).

Video

@v4/video-app ships landing, detail, collection, playlist and category factories over ghost-video, and every route takes client, media and locale: the landing reads its settings and the category block, the category page its category's name, the detail route the sections (a competition's slug at /video/<slug> is that competition's video home), and every list each video's competition marker. The landing factory resolves the editor-controlled siteSettings.defaultVideoLandingPage ahead of the config slug, so an editor can re-point /video without a deploy. It owns its own Contentful query and codegen, exactly as @v4/content-app does. See Media and video architecture for the media layer the factories sit on.

Basketball

@v4/basketball-app registers twelve routes in packages/basketball-app/routes.json — teams, players, coaches, games, standings and stats, each with a -path catch-all. Their deps are deliveryClient, locale, competitionEngine and, for the areas that show video, media. A bare area address redirects to the default competition's list (/en/teams → /en/teams/euroleague); the catch-all is a competition's list (with any filter pairs) or one team's, player's, coach's or game's page. See Basketball and the Competition Engine.

The DOMAIN_PACKAGES map in compose-club binds a domain to its route package: content → @v4/content-app, video → @v4/video-app, basketball → @v4/basketball-app. (It also names the template's football → @v4/football-app, which is never composed here and has no package in this repo.)