Files
lunarfront-app/planning/17_Backend_Technical_Architecture.md
Ryan Moon 5f8726ee4e Add planning documents for Forte music store platform
17 domain design docs covering architecture, accounts, inventory,
rentals, lessons, repairs, POS, payments, batch repairs, delivery,
billing, accounting, deployment, licensing, installer, and backend
tech architecture. Plus implementation roadmap (doc 18) and
personnel management (doc 19).

Key design decisions documented:
- company/location model (multi-tenant + multi-location)
- member entity (renamed from student to support multiple adults)
- Stripe vs Global Payments billing ownership differences
- User/location/terminal licensing model
- Valkey 8 instead of Redis
2026-03-27 14:51:23 -05:00

19 KiB

Music Store Management Platform

Backend Technical Architecture

TypeScript | Fastify | Bun | Drizzle ORM | BullMQ

Version 1.0 | Draft

1. Technology Rationale

The platform is TypeScript end-to-end. Every application — desktop, mobile, web portals, and API — shares one language, one type system, and one validation library. This is the primary driver of the technology choice: a solo developer maintaining the full stack gets maximum benefit from type safety flowing across every boundary.

Decision

Rationale

TypeScript everywhere

One language across API, desktop, mobile, and web. Shared types and validation. Refactoring catches errors across all applications simultaneously.

Fastify over Express

Better TypeScript support, faster performance, schema-based validation built in, plugin architecture cleaner than Express middleware.

Bun over Node.js

Native TypeScript execution without compilation step. Faster startup. Built-in compile-to-binary for self-hosted distribution. Compatible with Node.js ecosystem.

Drizzle over Prisma

Schema defined in TypeScript — no separate DSL file. Fully typed queries. SQL-transparent — generated queries are readable and predictable.

BullMQ over Celery

TypeScript-native. Redis-based — same Redis already used for caching. No separate Python worker process needed.

Zod for validation

Single schema definition runs on both backend (request validation) and frontend (form validation). Eliminates duplicate validation logic.

2. Monorepo Structure

The entire platform lives in a single monorepo managed by Turborepo. All packages share the same TypeScript compiler configuration, lint rules, and test runner. Internal packages are referenced by workspace path — no publishing required.

/ (monorepo root) turbo.json pipeline config package.json workspace root tsconfig.base.json shared TS config /packages /shared shared types, schemas, business logic /backend Fastify API (Bun runtime) /desktop Electron + React /mobile React Native (iOS) /web-portal React (customer facing) /admin React (internal) /tools /installer PowerShell scripts + Go tray app /migration AIM MSSQL -> Postgres ETL /codegen OpenAPI client generation

3. Shared Package

The shared package is the most important piece of the monorepo. It contains everything that is used by more than one application. No business logic lives in individual apps — it lives here.

/packages/shared/src /types account.ts Account, Member, PaymentMethod rental.ts Rental, RentalType, RentalStatus lesson.ts Enrollment, LessonSession, LessonPlan repair.ts RepairTicket, RepairBatch, RepairPart inventory.ts Product, InventoryUnit, RepairPart payment.ts Transaction, PaymentMethod, Subscription accounting.ts JournalEntry, AccountCode license.ts License, LicenseModules index.ts re-exports everything /schemas Zod schemas (frontend + backend validation) account.schema.ts rental.schema.ts lesson.schema.ts repair.schema.ts inventory.schema.ts payment.schema.ts /business-logic rto.ts rent-to-own equity calculations pricing.ts discount engine, min price enforcement billing.ts proration calculations, billing groups license.ts license verification, module checks accounting.ts journal entry generation rules /constants modules.ts MODULE_IDS, feature flags tax.ts tax rate helpers /utils currency.ts formatting, rounding helpers dates.ts billing date utilities

3.1 Zod Schema Example — Shared Validation

// packages/shared/src/schemas/rental.schema.tsimport { z } from 'zod'export const RentalCreateSchema = z.object({ account_id: z.string().uuid(), member_id: z.string().uuid(), inventory_unit_id: z.string().uuid(), rental_type: z.enum([ 'month_to_month', 'rent_to_own', 'short_term', 'lease_purchase' ]), monthly_rate: z.number().positive().max(9999), deposit_amount: z.number().min(0).optional(), billing_anchor_day: z.number().int().min(1).max(28), billing_group: z.string().max(50).optional(), rto_purchase_price: z.number().positive().optional(), rto_equity_percent: z.number().min(0).max(100).optional(),})export type RentalCreate = z.infer// Used in backend: request body validation// Used in desktop: rental intake form validation// Used in mobile: same form, same rules// One definition, zero drift

3.2 Business Logic Example — RTO Calculation

// packages/shared/src/business-logic/rto.tsexport function calculateRTOEquity(params: { monthlyRate: number equityPercent: number paymentsReceived: number}): { equityAccumulated: number equityPerPayment: number} { const equityPerPayment = (params.monthlyRate * params.equityPercent) / 100 const equityAccumulated = equityPerPayment * params.paymentsReceived return { equityAccumulated, equityPerPayment }}export function calculateBuyoutAmount(params: { purchasePrice: number equityAccumulated: number}): number { return Math.max(0, params.purchasePrice - params.equityAccumulated )}// Same calculation runs on API, desktop, and portal// Customer sees same number the staff sees

4. Backend Package

The backend is a Fastify application running on the Bun runtime. It handles all API requests, payment provider integrations, background job scheduling, and PDF generation. For self-hosted deployments it compiles to a single standalone binary.

4.1 Directory Structure

/packages/backend/src main.ts entry point — registers plugins, starts server /plugins auth.ts JWT verification, role extraction license.ts license loading, module guard database.ts Drizzle connection pool redis.ts Redis connection cors.ts error-handler.ts /routes /v1 accounts.ts rentals.ts lessons.ts repairs.ts inventory.ts payments.ts accounting.ts reports.ts admin.ts webhooks.ts payment processor webhooks health.ts /services business logic — thin routes call services account.service.ts rental.service.ts lesson.service.ts repair.service.ts billing.service.ts accounting.service.ts pdf.service.ts notification.service.ts /db schema/ Drizzle table definitions accounts.ts rentals.ts lessons.ts repairs.ts inventory.ts payments.ts accounting.ts migrations/ Drizzle migration files index.ts db client export /payment-providers base.ts PaymentProvider interface stripe.ts Stripe implementation global-payments.ts Global Payments implementation factory.ts returns correct provider per store /jobs BullMQ workers billing.job.ts daily GP billing scheduler reminders.job.ts renewal and payment reminders backup.job.ts self-hosted backup trigger reports.job.ts scheduled report generation scheduler.ts registers all jobs with cron /license verify.ts Ed25519 signature verification guard.ts requireModule() Fastify hook

4.2 Route Pattern

Routes are thin — they validate input using shared Zod schemas, call a service, and return the result. All business logic lives in services.

// routes/v1/rentals.tsimport { FastifyPluginAsync } from 'fastify'import { RentalCreateSchema } from '@platform/shared'import { RentalService } from '../../services/rental.service'const rentals: FastifyPluginAsync = async (fastify) => { fastify.post('/', { preHandler: [ fastify.authenticate, // JWT check fastify.requireModule('MOD-RENTALS'), // license check fastify.requireRole('staff'), // role check ], schema: { body: RentalCreateSchema, // Zod validation } }, async (request, reply) => { const rental = await RentalService.create( fastify.db, request.companyId, request.body, request.user ) return reply.code(201).send(rental) } ) fastify.get('/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const rental = await RentalService.getById( fastify.db, request.companyId, request.params.id ) if (!rental) return reply.code(404).send() return rental } )}export default rentals

4.3 Drizzle Schema Example

// db/schema/rentals.tsimport { pgTable, uuid, numeric, integer, timestamp, boolean, text, pgEnum} from 'drizzle-orm/pg-core'import { accounts } from './accounts'import { inventoryUnits } from './inventory'export const rentalTypeEnum = pgEnum('rental_type', [ 'month_to_month', 'rent_to_own', 'short_term', 'lease_purchase'])export const rentalStatusEnum = pgEnum('rental_status', [ 'active', 'returned', 'cancelled', 'completed'])export const rentals = pgTable('rental', { id: uuid('id').primaryKey().defaultRandom(), company_id: uuid('company_id').notNull(), account_id: uuid('account_id') .notNull() .references(() => accounts.id), inventory_unit_id: uuid('inventory_unit_id') .references(() => inventoryUnits.id), rental_type: rentalTypeEnum('rental_type').notNull(), status: rentalStatusEnum('status') .notNull().default('active'), monthly_rate: numeric('monthly_rate', { precision: 10, scale: 2 }).notNull(), billing_anchor_day: integer('billing_anchor_day').notNull(), billing_group: text('billing_group'), stripe_subscription_id: text('stripe_subscription_id'), rto_purchase_price: numeric('rto_purchase_price', { precision: 10, scale: 2 }), rto_equity_percent: numeric('rto_equity_percent', { precision: 5, scale: 2 }), rto_equity_accumulated: numeric('rto_equity_accumulated', { precision: 10, scale: 2 }) .default('0'), legacy_id: text('legacy_id'), created_at: timestamp('created_at').defaultNow(), updated_at: timestamp('updated_at').defaultNow(),})// Fully typed — TypeScript infers the shape from the schemaexport type Rental = typeof rentals.$inferSelectexport type RentalInsert = typeof rentals.$inferInsert

4.4 Payment Provider Interface

// payment-providers/base.tsexport interface PaymentProvider { readonly name: string // Customers createCustomer(account: Account): Promise deleteCustomer(processorId: string): Promise // Payment methods createSetupSession(): Promise attachPaymentMethod( customerId: string, token: string ): Promise // One-time charges charge(params: ChargeParams): Promise refund(params: RefundParams): Promise // Subscriptions createSubscription( params: SubscriptionParams ): Promise addSubscriptionItem( subscriptionId: string, item: SubscriptionItem ): Promise removeSubscriptionItem( subscriptionId: string, itemId: string ): Promise cancelSubscription(id: string): Promise pauseSubscription(id: string): Promise resumeSubscription(id: string): Promise changeBillingAnchor( subscriptionId: string, day: number ): Promise // Terminal discoverReaders(): Promise<Reader[]> collectPayment( params: TerminalPaymentParams ): Promise // Webhooks parseWebhookEvent( payload: string, signature: string ): Promise}// Factory returns correct provider per storeexport function getPaymentProvider( store: Store): PaymentProvider { switch (store.payment_processor) { case 'stripe': return new StripeProvider(store) case 'global_payments': return new GlobalPaymentsProvider(store) default: throw new Error('Unknown processor') }}

4.5 BullMQ Billing Scheduler

// jobs/billing.job.tsimport { Worker, Queue } from 'bullmq'import { getPaymentProvider } from '../payment-providers/factory'export const billingQueue = new Queue('billing', { connection: redis, defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } }})export const billingWorker = new Worker( 'billing', async (job) => { const { companyId, entityType, entityId } = job.data const store = await StoreService.getById(db, companyId) const provider = getPaymentProvider(store) if (entityType === 'rental') { const rental = await RentalService.getById( db, companyId, entityId ) await provider.charge({ customerId: rental.account.processor_customer_id, amount: rental.monthly_rate, description: Rental - ${rental.product_name}, metadata: { rental_id: rental.id } }) await AccountingService.recordRentalPayment(db, rental) } }, { connection: redis, concurrency: 5 })// Scheduler registers daily cron// jobs/scheduler.tsawait billingQueue.add('daily-billing', { trigger: 'cron' }, { repeat: { pattern: '0 0 * * *' } } // midnight daily)

5. Bun Runtime

Bun is a fast JavaScript/TypeScript runtime that executes TypeScript natively without a compilation step. For development this means running the backend directly from .ts files. For self-hosted deployment it compiles to a single standalone binary.

5.1 Development

Run backend directly — no tsc step neededbun run src/main.ts# Watch mode — restarts on file changesbun --watch run src/main.ts# Run testsbun test

5.2 Self-Hosted Binary Compilation

Compile to standalone binary# No Bun or Node.js runtime required on target machinebun build src/main.ts \ --compile \ --outfile platform-api \ --target bun-linux-x64 # or bun-linux-arm64# Result: single ~50-80MB executable# Ships inside Docker image — no source code included# Multi-arch build in CI:bun build src/main.ts --compile \ --outfile platform-api-amd64 --target bun-linux-x64bun build src/main.ts --compile \ --outfile platform-api-arm64 --target bun-linux-arm64

5.3 Docker Image

DockerfileFROM oven/bun:1 AS builderWORKDIR /buildCOPY packages/shared ./packages/sharedCOPY packages/backend ./packages/backendRUN cd packages/backend && \ bun install --frozen-lockfile && \ bun build src/main.ts \ --compile --outfile /build/platform-api# Runtime image — binary only, no sourceFROM ubuntu:24.04RUN apt-get update && apt-get install -y \ ca-certificates && rm -rf /var/lib/apt/lists/*COPY --from=builder /build/platform-api /app/platform-apiEXPOSE 8000CMD ["/app/platform-api"]# Result:# No TypeScript source in image# No Bun runtime in final image# No node_modules# Just the compiled binary + Ubuntu base

6. Drizzle ORM

Drizzle defines the database schema in TypeScript. All queries are fully typed — TypeScript knows the shape of every query result. Migrations are generated automatically from schema changes.

6.1 Typed Queries

// Fully typed — TypeScript infers return type from schemaconst rental = await db.query.rentals.findFirst({ where: and( eq(rentals.id, id), eq(rentals.company_id, companyId) // multi-tenant scoping ), with: { account: { with: { students: true } }, inventoryUnit: { with: { product: true } } }})// TypeScript knows: rental.account.students[0].first_name// No any, no casting, no guessing// Update with type safetyawait db.update(rentals) .set({ status: 'returned', // TypeScript enforces enum rto_equity_accumulated: '450.00' }) .where(eq(rentals.id, id))

6.2 Migrations

Generate migration from schema changesbunx drizzle-kit generate# Apply migrationsbunx drizzle-kit migrate# Migrations are plain SQL files — readable and reviewable# /packages/backend/src/db/migrations/# 0001_initial_schema.sql# 0002_add_repair_parts.sql# 0003_add_lesson_plans.sql# Applied automatically on startup in production# Self-hosted: runs during update process

7. Complete Tech Stack

Layer

Technology

Notes

Language

TypeScript 5.x

All packages — API, desktop, mobile, web

Runtime

Bun

Native TS execution, compile-to-binary for self-hosted

API Framework

Fastify

Fast, TypeScript-first, schema validation built in

ORM

Drizzle ORM

TypeScript schema, fully typed queries, SQL-transparent

Validation

Zod

Shared schemas — frontend and backend use same definitions

Job Queue

BullMQ

Redis-based, TypeScript-native, retry logic built in

Database

PostgreSQL 16

Aurora on SaaS, Docker container on self-hosted

Cache / Queue broker

Redis 7

Session cache, BullMQ broker, rate limiting

Desktop

Electron + React

Windows/Mac/Linux, imports from shared package

Mobile

React Native

iOS, Stripe Terminal Bluetooth, imports from shared

Web portals

React + Vite

Customer portal + admin panel

PDF generation

Puppeteer

HTML templates rendered to PDF for invoices/reports

Auth

Clerk or Auth0

JWT-based, RBAC, handles MFA

Payments — Stripe

stripe npm package

Official TypeScript SDK

Payments — GP

globalpayments-sdk

Node.js SDK

Monorepo

Turborepo

Build pipeline, caching, workspace management

Testing

Vitest

Fast, native ESM, compatible with Bun

Tray app

Go

System tray manager for self-hosted — tiny binary

Installer

PowerShell → Inno Setup

Phase 1: PS1 script. Phase 2: .exe wrapper.

8. CI/CD Pipeline

GitHub Actions handles the full build and release pipeline. SaaS deployments push to EKS. Self-hosted releases push compiled binaries to AWS ECR.

On push to main: 1. Typecheck all packages (tsc --noEmit) 2. Lint (ESLint) 3. Test (vitest) 4. Build shared package 5. Bun compile backend → binary (amd64 + arm64) 6. Docker build → push to ECR registry.platform.com/platform:sha-abc123 registry.platform.com/platform:latest 7. Update version registry APIOn tag (release): Same as above plus: 8. Tag Docker image with version registry.platform.com/platform:1.6.0 9. Build Electron desktop app (auto-updates via electron-updater) 10. Build React Native iOS app (TestFlight distribution) 11. Deploy SaaS to EKS (rolling update) 12. Update changelog and release notes

9. Local Development Setup

Prerequisites: Bun, Docker Desktop, Node.js (for tools)# Clone and installgit clone https://github.com/lunarfront/platformcd platformbun install# Start dependencies (Postgres + Redis)docker compose -f docker-compose.dev.yml up -d# Run database migrationscd packages/backendbunx drizzle-kit migrate# Start all packages in dev modebun run dev # Turborepo starts: # backend localhost:8000 # admin localhost:3001 # web-portal localhost:3002 # desktop Electron window# Run testsbun testbun test --watch