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
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