A restaurant owner wants three things from a digital menu: customers scan a QR code at the table and see the menu instantly, the staff can change a price in ten seconds, and nobody ever sees another restaurant’s data. MenuApp is the platform I built to do exactly that for many restaurants at once. It is a multi-tenant SaaS with Spring Boot: one backend, one database, and a hard wall between customers.

This case study walks through how it works and how I built it, step by step, with the code that matters. If you are planning a SaaS product of your own, most of these decisions will apply to you too.

The project at a glance

What it is A QR menu platform for restaurants (Morocco first, wider MENA region planned)
Who uses it Restaurant owners and their staff (dashboard), diners (public menu), platform admins
Backend Spring Boot 4 on Java 21, PostgreSQL, Redis, JWT authentication, Flyway migrations
Front ends Two React apps built with Vite: the owner dashboard and the public menu
Infrastructure Docker, Nginx with wildcard subdomains, Prometheus metrics, GitHub Actions CI with a Trivy image scan, daily database backups
Multi-tenant SaaS with Spring Boot in practice: the MenuApp public menu on a phone, with categories, a category list and an item
The public menu a diner opens from the table QR code (demo restaurant and data).

The problem a menu SaaS has to solve

Building one digital menu is easy. Building a product that hundreds of restaurants can sign up for is a different job, because every feature now has a second question attached: whose data is this? A menu item, a QR code, a staff account and a scan statistic all belong to exactly one restaurant, and every query in the system has to respect that.

The other requirements came from how restaurants actually work:

MenuApp menu management screen listing categories with item counts
Menu management: categories and items the staff can edit in seconds (demo data).

Architecture

I chose a single database with a tenant_id column on every tenant-owned table, rather than one database or schema per restaurant. For a product where each tenant has a small amount of data (a menu, a few users, some QR codes), shared tables are cheaper to run, simpler to migrate and easy to back up. The cost is that isolation becomes the application’s job, so I made it the first thing the request pipeline does and the hardest thing to get wrong.

Diner's phone ──► marios.menuapp.com ──► Nginx (wildcard subdomain) ──► public menu app (React)
                                                │
Owner's browser ──► app.menuapp.com ────────────┤
                                                ▼
                                   Spring Boot API (/api/v1)
                                   ├─ TenantFilter: who is this request for?
                                   ├─ Hibernate tenant filter on every query
                                   ├─ Redis: menu cache, QR code lookup, rate limits
                                   └─ PostgreSQL (Flyway-managed schema)

The code follows a layered structure: domain (entities and repositories), application (services such as menus, QR codes, analytics, users), web (controllers, DTOs, filters) and infrastructure (persistence helpers). Storage for uploaded images sits behind a StoragePort interface with a local implementation, so moving images to S3 later means writing one adapter, not touching the services.

Step 1: Decide, on every request, which tenant it belongs to

The first question for each request is “which restaurant is this for?”. There are three legitimate answers, and the order in which they are checked is a security decision:

  1. The caller’s JWT. When a restaurant user is logged in, the tenant comes from their token, never from anything they send in the request.
  2. An X-Tenant-Id header, only for platform admins. Support staff sometimes need to act inside a specific restaurant. The header is honoured only when the token belongs to a SUPER_ADMIN; for anyone else it is ignored.
  3. The subdomain, for the public menu. marios.menuapp.com resolves the slug marios to a tenant. Anonymous visitors can read a menu this way, and nothing else.

The resolved tenant is stored in a ThreadLocal for the duration of the request, and cleared in a finally block. That last line matters: application servers reuse threads, and a tenant left behind on a thread would leak into the next request.

public final class TenantContext {
    private static final ThreadLocal<UUID> CTX = new ThreadLocal<>();

    public static void set(UUID id) { CTX.set(id); }
    public static void clear()      { CTX.remove(); }
    public static UUID require() {
        UUID id = CTX.get();
        if (id == null) throw new TenantContextMissingException();
        return id;
    }
}

// In TenantFilter (a OncePerRequestFilter):
try {
    UUID tenantId = extractTenantId(parseJwtClaims(req));   // 1. from the user's own token
    if (tenantId != null) { TenantContext.set(tenantId); chain.doFilter(req, res); return; }
    // 2. X-Tenant-Id, only for verified SUPER_ADMIN tokens
    // 3. subdomain slug for the public menu
    ...
} finally {
    TenantContext.clear();   // never leak a tenant into the next request on this thread
}

require() throws instead of returning null. If any code path ever reaches a tenant-scoped service without a tenant, the request fails loudly rather than quietly running an unscoped query.

Step 2: Make every query tenant-scoped by default

Remembering to add WHERE tenant_id = ? to every query is exactly the kind of rule that a tired developer breaks at 11 pm. Hibernate filters move the rule into the entity definition:

@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = UUID.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class MenuItem { ... }

public abstract class TenantAwareService {
    @PersistenceContext protected EntityManager em;

    protected void enableTenantFilter() {
        em.unwrap(Session.class)
          .enableFilter("tenantFilter")
          .setParameter("tenantId", TenantContext.require());
    }
}

Every service that touches tenant data extends TenantAwareService and enables the filter, so repository queries are scoped automatically. For lookups by ID I add an explicit check as a second layer, for example findByIdAndTenantId(id, tenantId): a restaurant guessing another restaurant’s category ID gets a “not found”, not the category.

Step 3: Users, roles and the rules between them

Each restaurant has an owner, who can add managers and viewers. A manager can add viewers but not other managers. Platform staff have a separate super admin role. The endpoint is protected by role, and the service checks the hierarchy before creating anyone:

@PostMapping("/api/v1/users")
@PreAuthorize("hasAnyRole('OWNER', 'MANAGER')")
public UserResponse create(@Valid @RequestBody CreateUserRequest req) {
    return userService.createUser(req);   // OWNER → MANAGER/VIEWER, MANAGER → VIEWER only
}

Passwords are validated (length, upper case, number, special character) and hashed with BCrypt. A super admin creates a new restaurant and its owner account in a single transaction, so there is never a tenant without an owner or an owner without a tenant.

MenuApp user management with owner, manager and viewer roles
Each restaurant manages its own staff accounts and roles (demo data).

Step 4: Plans enforced by the backend

A free plan that is only limited in the user interface is not a plan; anyone with a browser’s developer tools can bypass it. MenuApp enforces limits in the service layer, before anything is saved. The free plan allows ten menu items and a limited number of menu languages:

private static final int FREE_PLAN_ITEM_LIMIT = 10;

public ItemResponse createItem(CreateItemRequest req) {
    UUID tenantId = currentTenantId();
    MenuCategory cat = catRepo.findByIdAndTenantId(req.categoryId(), tenantId)
            .orElseThrow(() -> new ResourceNotFoundException("Category"));
    enforceFreePlanItemLimit(tenantId);        // throws PlanLimitExceededException
    enforcePlanLanguageLimit(req.nameI18n());  // menu translations per plan
    ...
}

The exception is mapped to a clear API error (“Free plan is limited to 10 menu items. Upgrade to Pro for unlimited items.”), which the dashboard turns into an upgrade prompt. Because the rule lives in one place, changing a plan later is a one-line change, not a hunt through the front end.

Step 5: A public menu that stays fast under load

The public menu is read hundreds of times for every time it is edited, which makes it a perfect cache candidate. The full menu of a restaurant is cached per tenant and evicted whenever anything in that restaurant’s menu changes:

@Cacheable(value = "menus", key = "#tenantId")
public MenuResponse getFullMenu(UUID tenantId) { ... }

@CacheEvict(value = "menus", key = "#root.target.getTenantId()")
public ItemResponse createItem(CreateItemRequest req) { ... }   // same on update and delete

Diners never see a stale price after an owner saves, and the database is not hit at all for repeat views.

Step 6: QR codes that resolve in one Redis lookup

Each table gets its own QR code, so the owner can see which tables scan. When a code is created, the service stores a mapping from the short code to the tenant in Redis, then generates a high-error-correction PNG with ZXing (it still scans when the printed sticker is scratched):

public QrProvisionResponse provision(String tableLabel) {
    String code = generateUniqueCode();
    QrCode qr = new QrCode();
    qr.setTenant(em.getReference(Tenant.class, currentTenantId()));
    qr.setCode(code);
    qr.setTableLabel(tableLabel);
    qrRepo.save(qr);
    // code → tenant in Redis, so a scan never needs a database query to know where to go
    redis.opsForValue().set("qr:" + code, currentTenantId().toString());
    return new QrProvisionResponse(qr.getId(), code, buildQrUrl(code), tableLabel);
}

Step 7: Analytics that never slow down a scan

Recording a scan is useful; making the diner wait for it is not. Scans and dish views are written asynchronously. The trade-off is written down in the code: losing one analytics row in a crash is acceptable, a slow menu is not. The async method receives the tenant explicitly, because the request’s ThreadLocal does not exist on the background thread.

// Called from the scan redirect path, which must stay fast.
@Async
public void recordScanAsync(UUID tenantId, UUID qrCodeId, String referrer, String deviceInfo) {
    QrCodeScan scan = new QrCodeScan();
    scan.setTenant(em.getReference(Tenant.class, tenantId));
    scan.setQrCode(em.getReference(QrCode.class, qrCodeId));
    ...
}
MenuApp owner dashboard showing total scans, scans today, peak hour and most viewed dishes
Owner dashboard: QR scans and the most viewed dishes (demo data).

Step 8: Subdomains for every restaurant

Every restaurant gets its own address, slug.menuapp.com, without any DNS work per customer: a wildcard DNS record and a wildcard certificate point every subdomain at the same Nginx server, which serves the public menu app and forwards API calls with the original Host header so the backend can resolve the slug:

server {
    listen 443 ssl http2;
    server_name *.menuapp.com;
    root /var/www/menu;

    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;          # the backend reads the restaurant from it
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location / { try_files $uri /index.html; }   # single-page app fallback
}

The admin dashboard lives on its own host, and /actuator endpoints are blocked at the proxy so health and metrics are never exposed publicly.

Security and production readiness

Before calling the platform production-ready I audited it and wrote the findings down, fixed the issues and kept a roadmap for the rest. The main points:

What I would tell anyone building a multi-tenant SaaS with Spring Boot

Building something similar?

The same foundation fits most B2B products: booking systems, client portals, internal tools sold to several companies, white-label apps. A first version usually includes multi-tenant accounts and roles, the core feature, plan limits, an admin dashboard and a deployment pipeline. If you are planning one, I can help you scope it, build it, or review an existing codebase for tenant-isolation and security gaps.

Official resources for multi-tenant SaaS with Spring Boot

Documentation and references for multi-tenant SaaS with Spring Boot: