Skip to content

Architecture

Jens Gross edited this page Sep 8, 2026 · 1 revision

Courtside is a modular monolith: one deployable artifact, one database, and module boundaries that the build refuses to let you cross. This page explains the boundaries and the handful of decisions that account for most of the shape of the code. The reasoning behind them is in docs/design.md; what follows is the map you need before that document is readable.

A module has two zones

Every module is a package under org.courtside, and it is split in two.

The base package is the module's API. Anything public there may be used by any module that declares a dependency on it. Every sub-package, internal included, is implementation detail, and a reference to it from another module fails the build. booking is the clearest example: Booking, BookingService and CreateBookingCommand sit in the base package, and booking/internal, booking/series and booking/web are closed to everybody else. A controller is as internal as a repository here.

The rule when you add a class is to put it in internal unless another module needs it. The tree does not fully obey that yet — several types sit in a base package with no caller outside their own module, some of them dragged there by a public method on a type that had to be visible. Treat the base package as what other modules may reach, not as proof that they do.

The boundary is declared, not implied

Each module states what it may use, in its own package-info.java:

@org.springframework.modulith.ApplicationModule(
        displayName = "Booking",
        allowedDependencies = {"facility", "card", "identity", "member", "rules", "config"})
package org.courtside.booking;

ModularityTests runs Spring Modulith's ApplicationModules.verify() over the whole application, so an undeclared dependency and a reach into somebody's internal are both build failures rather than review findings. Read the annotations in the tree rather than a list on a wiki page; they are the authority and they move.

Two modules are declared shared in CourtsideApplication and may therefore be used by everyone: shared, and api, which holds the types the OpenAPI generator writes and carries no logic of its own. Being shared applies to the base package only. shared.web is still internal, even though shared is shared.

The dependency direction

booking is the core. It depends on facility, card, identity, member, rules and config, and nothing it depends on may depend back on it. config sits at the bottom and depends on nothing at all.

The modules that need to know what happened in the core do not call it. They listen. audit writes the change log that way, and notification sends every message that way, so the booking core knows nothing about email and nothing about the change log. Adding a consumer is additive: it subscribes, and no module it observes changes by one line.

Not every dependency inverts, and the graph is honest about it. dataexchange declares booking, audit and notification directly, because answering what the instance holds about one person means asking each of them a question rather than waiting for an event.

The two kinds of consumer want different guarantees, and both are served from one publication. audit listens before the commit and writes its row in the same transaction, which is the property that makes a log an audit log: no commit without a row, and a change that rolls back is a change never recorded. Everything else listens after the commit, through Spring Modulith's event publication registry, which stores the publication in that same transaction and redelivers after a restart that interrupted it, so a consumer interrupted mid-delivery runs again rather than never. A consumer that waits on something outside the instance, as sending mail does, also takes its own executor, so an unreachable mail server holds up neither the request that published the event nor the other consumers of it.

demo, performance and securityassessment are the odd ones out, not because they declare the most dependencies — six or seven of fifteen, about what booking and dataexchange declare — but because they exist only to fill a database with disposable data. Each refuses to start unless its environment guard confirms that database is the throwaway one it names: courtside_dev, courtside_perf, courtside_security.

Five decisions that explain most of the code

PostgreSQL is not interchangeable. Two bookings cannot occupy the same court at the same time, and that is enforced by a GiST exclusion constraint in V3__booking.sql, not by a check in Java:

CONSTRAINT court_allocation_no_overlap EXCLUDE USING gist (
    court_id WITH =,
    tstzrange(starts_at, ends_at, '[)') WITH &&
) WHERE (status <> 'CANCELLED')

Application code cannot make that promise under concurrency, so it does not try. It catches the constraint violation and translates it. This is also why the occupancy tests run against a real PostgreSQL: a mock would agree with whatever the code believes.

Everything that occupies a court is one entity. A member booking, a training session, a league match and a court closure are all a Booking. What differs is the booking_card it carries. A new kind of appointment is a row a club inserts, not a deployment.

The API document is the source of truth. src/main/resources/api/openapi.yaml is written by hand; the Java interfaces and models in org.courtside.api are generated from it. Controllers implement those interfaces and carry no routing annotations of their own, so a route, its media types and its parameter names all come from the document. You change an endpoint by changing the document. The single exception is ContainerErrorController, which answers what the servlet container produces before any of this is reached, and therefore maps its own path. The generated types are the one place in the codebase where mutable setters are normal, because a transport shape has no invariants to protect.

Rules are data, where a club has a say. Each booking rule is a BookingRule bean, and RuleEngine runs all of them and collects every violation instead of stopping at the first. What varies is where a rule gets its numbers: how many bookings a member may hold open, and how far ahead they may book, are rows a club edits; the opening hours and the slot grid come from club configuration; and a rule such as "not in the past" has nothing to configure at all. A violation carries an i18n code and its parameters, never rendered text, so the frontend and the email templates decide the wording.

Configuration over code. Anything a club board would plausibly want to change belongs in the database and the admin UI. Only infrastructure belongs in the environment. The corollary the code is held to: everything a club enters, a club can correct, including a username and a password.

Errors

Domain failures are typed exceptions translated to RFC 9457 problem details in a @RestControllerAdvice. A problem type is a URN of the form urn:courtside:error:<kebab-slug> rather than a URL, because every club runs its instance under its own domain and this product owns no host to point at.

IllegalArgumentException is not a domain exception here, and a failure a user can cause must never be reported with one. A test lists every file allowed to throw it, and that list is short: value types guarding against a programming error, plus the shared failure base class. Everything a request can reach gets Bean Validation or a failure type carrying an i18n code.

Where to go next

  • docs/design.md section 3 for the architecture in full, and section 0 for what is built as opposed to designed.
  • docs/data-model.md for the schema table by table.
  • CLAUDE.md for the conventions these boundaries are maintained under.
  • Home for everything else.