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<typeof RentalCreateSchema>// 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<ProcessorCustomer>  deleteCustomer(processorId: string): Promise<void>  // Payment methods  createSetupSession(): Promise<SetupSession>  attachPaymentMethod(    customerId: string,    token: string  ): Promise<PaymentMethod>  // One-time charges  charge(params: ChargeParams): Promise<ChargeResult>  refund(params: RefundParams): Promise<RefundResult>  // Subscriptions  createSubscription(    params: SubscriptionParams  ): Promise<Subscription>  addSubscriptionItem(    subscriptionId: string,    item: SubscriptionItem  ): Promise<SubscriptionItem>  removeSubscriptionItem(    subscriptionId: string,    itemId: string  ): Promise<void>  cancelSubscription(id: string): Promise<void>  pauseSubscription(id: string): Promise<void>  resumeSubscription(id: string): Promise<void>  changeBillingAnchor(    subscriptionId: string,    day: number  ): Promise<ProrationResult>  // Terminal  discoverReaders(): Promise<Reader[]>  collectPayment(    params: TerminalPaymentParams  ): Promise<TerminalPaymentResult>  // Webhooks  parseWebhookEvent(    payload: string,    signature: string  ): Promise<WebhookEvent>}// 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