Splitting a travel platform into services
Rebuilding a travel platform as four repos — infrastructure, storage, a NestJS API and a Next.js app — Authentik owns identity, Makefiles build the substrate.
- NestJS
- Next.js
- TypeScript
- Docker
- Makefile
A rebuild of the tour booking app I wrote earlier, this time split into four repositories that deploy independently instead of one block that deploys together.
Why not keep the monolith#
The old version worked: one Node.js API, one MongoDB, serving both web and mobile. The problem was never speed or stability — it was that everything shared a single lifecycle. Changing how data was stored meant rebuilding the API. Changing how login worked meant editing the same codebase that held the booking logic.
So when I rebuilt it, I cut along rate of change rather than along domains.
Databases barely move for months. The gateway and the identity provider move a few
times a quarter. The business API moves daily. There is no reason those three rhythms
should share a single git push.
Four repositories, four lifecycles#
Storage brings up the data tier: PostgreSQL for money and orders, MongoDB for reviews and tour detail, Redis for caching, Qdrant reserved for vector search later. The whole cluster lives in one compose project, so it comes up or goes down as a unit rather than one container at a time.
Infrastructure brings up everything around it: APISIX as the gateway (with etcd
and its dashboard), Authentik as the identity provider, RabbitMQ as the place events
go. One folder each, one compose.yml each.
Business services is the NestJS API and Web is a Next.js App Router app with locale-based routing. Those two are where I actually write code day to day.
Infrastructure behind one command#
What I wanted to avoid was a Makefile that needs editing every time a component is added. So it discovers directories instead of listing them:
# Any subdirectory holding a compose.yml is an infrastructure component.
COMPOSE_DIRS := $(sort $(dir $(wildcard */compose.yml)))
SERVICES := $(COMPOSE_DIRS:/=)
up:
@for svc in $(SERVICES); do \
docker compose -f $$svc/compose.yml --project-directory $$svc up -d; \
done
# up-apisix, up-authentik, up-rabbitmq... generated from SERVICES.
$(addprefix up-,$(SERVICES)): up-%:
@docker compose -f $*/compose.yml --project-directory $* up -dAdding a component is creating a folder. There is no list left to forget to update — exactly what I needed once the substrate had more moving pieces than I could hold in my head.
Identity lives outside the API#
This is the decision I rate highest. The API stores no passwords, issues no tokens,
and has no password_hash column. All of that belongs to Authentik.
The web app runs Authorization Code with PKCE, but the code-for-token exchange is
routed through the backend so the client_secret never reaches a browser. The API
does exactly one thing: verify the token signature against public keys fetched from
JWKS.
@Injectable()
export class AuthentikJwtStrategy extends PassportStrategy(
Strategy,
'authentik-jwt',
) {
constructor() {
const env = getEnv();
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
/* Public keys come from Authentik's JWKS and are cached locally. */
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
jwksUri: env.authentikJwksUri,
}),
audience: env.authentikAudience,
issuer: env.authentikIssuer,
});
}
}The users table in Postgres is therefore only a shadow: it holds the authentik_id
taken from the sub claim, plus an avatar and a phone number — things that belong to
the business, not to identity. Every authenticated request upserts that row, so
foreign keys from bookings and payments always have something to point at.
Data split by shape#
Tours, bookings and payments sit in Postgres because they need foreign keys and
CHECK constraints on status. Reviews, detailed itineraries and activity logs sit in
MongoDB because every record has a different shape. Redis caches tour listings, and
when Redis is unreachable the service disables its cache after three retries instead
of dying with it.
Outcome#
- Four repositories split by rate of change; the substrate rebuilds via
make up - Login, group-based roles and user sync genuinely run through Authentik
- The API covers tours, orders, bookings, payments (Stripe/VNPay), reviews, uploads
- The web app is wired to the API for login, profile and admin; tour and car listings still render mock data
- RabbitMQ events are published but nothing consumes them yet, and Qdrant is provisioned without the API using it