Repository navigation
Architecture
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.
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.
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.
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.
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.
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.
-
docs/design.mdsection 3 for the architecture in full, and section 0 for what is built as opposed to designed. -
docs/data-model.mdfor the schema table by table. -
CLAUDE.mdfor the conventions these boundaries are maintained under. - Home for everything else.