TheRare Visuals

Backend system design: bookings, jobs and money

How the Spring Boot API is organised: the data model, 99 schema migrations, role-based access, background jobs on Cloud Tasks, and the locks that stop two requests spending the same credit.

TheRare Engineering8 September 20269 min readIntermediate

What the backend is responsible for

The website is what customers see. The API is where the rules live: who may see which photos, how much a booking costs, whether a payment is real, when a reminder email goes out. It's a Spring Boot 3 application in Java 17, with about 70 controllers and 60 database entities.

This post explains how it's organised, and the patterns that kept it correct as it grew from a contact form into a full studio platform.

Layers: controller, service, repository

Every feature follows the same path through the code:

LayerJobExample
ControllerReceives HTTP, checks input, returns JSONCustomerController
ServiceThe business rules and transactionsCustomerBookingService
RepositoryTalks to the databaseMediaAssetRepository
EntityA Java class mapped to a tableMediaAsset
DTOThe shape sent to the browserRawFootageAssetResponse

The rule that makes this work: controllers don't contain business logic, and entities never go straight out as JSON. Controllers are thin. Entities are converted to DTOs, small records that contain only what the browser should see. That stops accidents like an internal field (a storage path, a staff note) leaking into an API response because someone added it to the entity.

The data model starts with a booking

Almost everything hangs off the booking: its customer, its assigned team, payments, photos and videos, messages, refund requests, AI edits. A booking moves through a clear lifecycle:

Text
INQUIRY → CONTACTED → NEGOTIATING → CONFIRMED → SHOOT_SCHEDULED
        → SHOOT_COMPLETED → EDITING → PREVIEW → FINAL_SETTLEMENT → DELIVERED
                                                     (or CANCELLED at any point)

Making the states explicit as an enum, instead of a handful of true/false flags, means every screen agrees on where a booking is, and rules can be written against states instead of combinations of flags.

Schema changes: 99 migrations and one rule

The database schema is managed by Flyway. Every change is a numbered SQL file (V1__init_schema.sql, V23__admin_rbac.sql, up to V99). On start-up, Flyway applies any the database hasn't seen yet, in order.

The one rule: never edit a migration that has already run. Production has already applied it, so your edit would never reach it, and databases would silently disagree. Always add a new file.

On top of that, Hibernate checks at start-up that every entity matches the real tables. If someone adds a field to a Java class but forgets the migration, the app refuses to start on their machine, long before it reaches customers.

A good migration also explains itself. This one, for example, starts with a comment saying why the new columns exist:

SQL
-- When a watermarked preview / HLS job was last queued. ... With more than
-- one instance, the old in-memory "already queued" set no longer dedupes,
-- so the claim lives here.
ALTER TABLE media_assets
    ADD COLUMN hls_preview_queued_at TIMESTAMPTZ,
    ADD COLUMN hls_clean_queued_at TIMESTAMPTZ,
    ADD COLUMN preview_image_queued_at TIMESTAMPTZ;

Every outside service has a stand-in

Storage, payments and background jobs each sit behind an interface with two implementations: the real one, and a local one for development.

InterfaceProductionLocal development
MediaStorageServiceGoogle Cloud StorageA folder on disk
PaymentGatewayServiceRazorpayA simulator that treats every payment as paid (never enabled where real money moves)
MediaJobDispatcherCloud Tasks queueOne background thread

A setting picks which one Spring creates. The result: a new developer can clone the repo, start Postgres in Docker, and run the whole platform, uploads and payments included, without a single cloud credential. And no feature code knows or cares which one it's talking to.

Email goes one step further. If the mail setting is misspelt, a fallback that logs loudly at start-up is used instead of crashing. A typo in an email setting shouldn't be able to stop people booking.

Roles and permissions

Staff log in to an admin panel with different powers. We have four roles: Owner, Investor, Manager and Team Member. Two design decisions are worth stealing.

Seniority isn't permission. An Investor ranks above a Manager in the business but has fewer powers: they can read bookings, payments and analytics to follow profit, and can't change anything. So roles don't inherit from each other. Each role is a named bundle of specific permissions:

Java
@PostMapping(consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('MEDIA_WRITE')")
public ResponseEntity<AdminMediaUploadResponse> upload(
        @PathVariable Long bookingId,
        @RequestParam("file") MultipartFile file,
        Authentication authentication) { ... }

Checking a permission on each method, rather than a role name or a URL pattern, means one controller can have different rules per endpoint, and adding a role later means editing a list, not hunting through every controller.

An enum, not a permissions database. Admin-editable roles would need a settings screen, extra tables and cache invalidation. A studio has four roles that rarely change, so the mapping lives in code, reviewed like any other change. Build the flexible version when you need it, not before.

There is also one staff table and one login flow for every role, instead of separate systems for owners, managers and photographers. Three login systems would mean three password-reset flows and three times the security surface.

Transactions and the order of things

Spring's @Transactional wraps a method so its database changes either all happen or none do. Two subtler patterns came up again and again.

Read-only by default. Listing methods are marked @Transactional(readOnly = true), which lets the database and Hibernate skip work, and documents intent.

Some work must wait until the transaction commits. When a staff member turns on previews for a video, we queue a job to make the watermarked version. If the job is queued inside the same transaction, two things go wrong: the job might run before the change is committed and see old data, and the queueing step's own database update can wait on a lock the outer transaction holds, while the outer transaction waits for it. That's a deadlock. So queueing runs after commit:

Java
AfterCommit.run(() -> claimAndQueue(kind, id, objectKey, flavour));

It's a tiny helper: if there's an active read-write transaction, it registers the work to run after a successful commit; otherwise it runs it straight away.

Concurrency: two requests, one credit

RareAI Studio gives each booking 20 free AI photo edits. What happens if a customer double-clicks "Generate" with one credit left? Two requests arrive at the same moment, both check the count, both see 1, and both spend it.

The fix is a row lock. The first thing the edit does is lock the booking's row:

Java
@Query(value = "select id from bookings where id = :bookingId for update", nativeQuery = true)
Long lockBooking(@Param("bookingId") Long bookingId);

FOR UPDATE makes the second request wait until the first transaction finishes. By then the credit is spent, and the second request sees 0. Failed edits don't use a credit, and the check happens before we pay Google for anything.

Concurrency without locks: the atomic claim

Video encoding has the opposite problem. Several requests might notice that a video needs encoding at the same time: the customer opening their gallery, a staff member pressing Rebuild, the gallery on another device. We want exactly one job queued.

Our first version kept a set of "already queued" ids in memory. That works with one server. With two, each has its own set, and both queue the job.

The fix moves the decision into the database with a conditional update:

Java
@Modifying
@Query("""
    UPDATE MediaAsset m SET m.hlsCleanQueuedAt = :now
    WHERE m.id = :id AND m.hlsCleanMasterKey IS NULL AND m.hlsCleanError IS NULL
      AND (m.hlsCleanQueuedAt IS NULL OR m.hlsCleanQueuedAt < :staleBefore)
    """)
int claimHlsClean(Long id, Instant now, Instant staleBefore);

The database runs each UPDATE one at a time for a given row. The first caller changes one row and gets back 1; everyone else finds the condition no longer true and gets 0. Only the caller who got 1 queues the job. The staleBefore part lets a claim expire, so a job that died silently doesn't block that video forever.

This pattern, "update where the state is still what I expect, then check how many rows changed", is one of the most useful tools in backend development. It works across any number of servers without a separate locking service.

Background jobs on a platform that pauses your CPU

Cloud Run gives a container CPU while it's handling a request. Once the response is sent, CPU drops to almost nothing. We learned this the hard way: our first encoder ran on a background thread after replying "queued", and on Cloud Run it barely ran at all.

The fix is to make every job its own HTTP request:

  1. The API puts a task on a Cloud Tasks queue, describing the job.
  2. Cloud Tasks POSTs that task to an internal endpoint on the encoder service, the same code deployed separately with 4 GB of memory, 2 CPUs and one request at a time, so encodes never slow down the website.
  3. The job runs inside that request, with full CPU, and returns when done.
  4. If it fails, Cloud Tasks retries with backoff. After the last attempt, the error is saved on the row, and staff see "Failed" with a Rebuild button.

Cloud Tasks allows at most 30 minutes per task, so a long film isn't one job. Each quality level (1080p, 720p, 480p, 360p) is a separate job. Part 6 covers the encoding itself.

Scheduled jobs (the 9 a.m. reminder emails, the hourly clip expiry, the 3:30 a.m. clean-up of abandoned uploads) run on the main service only. The encoder runs the same code with scheduling turned off, or every customer would get every reminder twice.

Things we chose not to build (yet)

A good design is as much about what you leave out:

  • No Redis. Rate limiting counts requests in memory. That's correct only while the API runs as a single instance, and the code says so in a comment. The day we scale out, it moves to a shared store.
  • No message broker. Cloud Tasks does retries and rate control for our volume without running Kafka or RabbitMQ.
  • No microservices. One API, plus one copy of it tuned for encoding. A small team can hold the whole thing in its head.

Each of those is a deliberate "not yet", written down with the reason, so whoever changes it later knows what assumption they're breaking.

Takeaways

  • Keep controllers thin and never send entities straight out as JSON.
  • Model lifecycles as explicit states.
  • Migrations are append-only, and the app should refuse to start if the schema doesn't match.
  • Put every outside service behind an interface with a local stand-in.
  • Check permissions, not role names.
  • Queue follow-up work after the transaction commits.
  • Use SELECT … FOR UPDATE when two requests can spend the same thing, and conditional updates when many callers race to claim a job.
  • Know how your platform bills CPU before you design background work.

Part 5 follows the heaviest data in the system: gigabytes of photos and video on their way in and out.

Found this useful?

Built something with what you learned here, or spotted a better way? Tell us. We work with freelance developers on Shootlo.