# Architecture Patterns **Domain:** Multi-tenant ISP Operations Management SaaS **Project:** NetForge **Researched:** 2026-03-04 **Confidence:** HIGH (established patterns, well-documented domain — verified against training knowledge through August 2025; external verification tools unavailable, document from domain expertise) --- ## Recommended Architecture NetForge is best structured as a **modular monolith with tenant-scoped data isolation**, not microservices. For a small-to-medium SaaS targeting ISPs with under 500-5,000 subscribers per tenant, microservices add operational complexity that outweighs their benefits. A monolith with clear internal module boundaries allows rapid iteration, is easier to reason about, and can be extracted later if one module scales differently. The core architectural principle: **every database query is scoped to a tenant_id**. This is enforced at the repository/data-access layer, not at the application layer. No business logic should ever bypass tenant scoping. ``` ┌─────────────────────────────────────────────────────────────────┐ │ Client Layer │ │ Admin Portal Staff Portal Collector App Client Portal │ │ (web, full) (web, full) (web, mobile) (web, limited)│ └──────────────────────────┬──────────────────────────────────────┘ │ HTTPS / REST or tRPC ┌──────────────────────────▼──────────────────────────────────────┐ │ API Gateway / Auth Layer │ │ - JWT validation │ │ - Tenant resolution (subdomain → tenant_id) │ │ - Role extraction │ │ - Route-level permission guards │ └──────────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────────┐ │ Application Modules (Monolith) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ │Subscriber│ │ Billing │ │ Payment │ │ Accounting │ │ │ │ Mgmt │ │ Engine │ │ Tracker │ │ (Ledger) │ │ │ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ │ Ticketing│ │ Job Order│ │Technician│ │ Inventory │ │ │ │ System │→ │ Workflow │ │ Mgmt │ │ (Assets) │ │ │ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ │ Expense │ │Dashboard │ │ Report │ │ Notification │ │ │ │ Tracking │ │& Metrics │ │ Engine │ │ (Internal) │ │ │ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Tenant Context Middleware │ │ │ │ (all modules receive tenant_id from request ctx) │ │ │ └──────────────────────────────────────────────────────────┘ │ └──────────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────────┐ │ Data Layer │ │ PostgreSQL (primary) Redis (session/cache) │ │ - tenant_id on all rows - rate limiting │ │ - Row-Level Security - background job queues │ │ - Soft deletes - temp calculations │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Component Boundaries ### 1. Auth and Tenant Resolution | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Auth Module | JWT issuance, refresh, logout, session management | All modules (provides tenant_id + role to request context) | | Tenant Resolver | Maps subdomain/slug to tenant_id, validates tenant exists and is active | Auth module, database | | Permission Guard | Route-level RBAC enforcement based on role from JWT | All route handlers | **Key rule:** The JWT payload carries `{ user_id, tenant_id, role }`. Every request arriving at the application layer already knows its tenant. No module may accept a request without tenant context. **Role matrix summary:** | Permission Area | Admin | Office Staff | Collector | Technician | Client | |----------------|-------|-------------|-----------|------------|--------| | Full system config | Yes | No | No | No | No | | Billing & subscriber mgmt | Yes | Yes | No | No | No | | Payment recording | Yes | Yes | Yes (own collections) | No | No | | Ticket creation | Yes | Yes | No | No | Yes | | Job order assignment | Yes | Yes | No | No | No | | Job order completion | Yes | No | No | Yes (own) | No | | Inventory adjustments | Yes | Yes | No | No | No | | Accounting / journal entries | Yes | No | No | No | No | | Own portal (view bills, pay) | No | No | No | No | Yes | --- ### 2. Subscriber Management | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Subscriber Module | Registration, plan assignment, status (active/suspended/disconnected), contact info | Billing engine (triggers first bill), Inventory (asset assignment), Auth (creates client portal account) | **Data owned:** `subscribers`, `service_plans`, `subscriber_plan_history` **Key boundary:** Subscriber management does not know about accounting. When a subscriber is activated, it emits an event (or calls a service interface) to the Billing engine. The Billing engine then creates the appropriate ledger entries via the Accounting module. Subscriber module never writes to the journal directly. --- ### 3. Billing Engine This is the most complex module. It must handle prepaid and postpaid models with different logic. | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Billing Engine | Bill generation (scheduled + manual), billing cycle tracking, invoice creation, prepaid/postpaid logic, due date calculation, overdue detection | Subscriber module (read plan), Accounting module (write journal entries), Notification module (triggers bill-ready notices) | **Prepaid logic:** Bill is generated before the service period begins. Subscriber must pay before activation or renewal. Overdue = service suspended. **Postpaid logic:** Bill is generated at end of service period. Subscriber pays after consuming service. Overdue = follow-up by collector. **Data owned:** `invoices`, `billing_cycles`, `billing_settings` (per tenant: cycle day, grace period, etc.) **Key boundary:** The Billing engine does not record payments — that is the Payment Tracker's responsibility. An invoice has a status field (`unpaid`, `partial`, `paid`, `overdue`) that is updated by the Payment Tracker when a payment is recorded against it. --- ### 4. Payment Tracker Tracks all money received. Collector-aware — the system must know which collector received which payment for reconciliation. | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Payment Tracker | Recording cash/online payments, linking payment to invoice, updating invoice status, collector balance tracking, reconciliation | Billing engine (read invoice, update status), Accounting module (write journal entry: cash received → accounts receivable cleared), Collector module (update collector running balance) | **Data owned:** `payments`, `collector_remittances`, `payment_methods` **Key design decision:** Payments are immutable once recorded. Corrections happen via reversal entries, not edits. This is essential for audit integrity and aligns with double-entry accounting principles. **Collector flow:** ``` Collector records cash payment from subscriber → Payment created (linked to collector + invoice) → Invoice status updated (partially or fully paid) → Collector running balance increases (they are holding the cash) → When collector remits to office: remittance event created → Collector balance decreases, office cash account increases → Accounting module writes journal entries for both events ``` --- ### 5. Accounting Module (Double-Entry Ledger) The accounting module is a foundational module that every money-touching module writes to, but it does not initiate transactions itself. | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Chart of Accounts | Tenant-specific account structure (assets, liabilities, equity, income, expense) | All money modules (read accounts to post to) | | Journal Entry Service | Creating balanced journal entries (debit = credit always), posting, voiding | Payment Tracker, Billing Engine, Expense Module, Inventory (asset capitalization) | | Report Engine | Generating Trial Balance, Balance Sheet, Income Statement from journal entries | No writes — read-only from journal | **Standard accounts (auto-provisioned per tenant at signup):** ``` Assets: - Cash (office) - Cash in Transit (collector-held) - Accounts Receivable (subscriber balances) - Inventory (network equipment) - Fixed Assets (deployed equipment) Liabilities: - (minimal in v1 — can add later) Equity: - Owner's Equity Income: - Subscription Revenue Expenses: - Technician Labor - Network Equipment (expense) - Operating Expenses ``` **Key rule:** Journal entries are never edited after posting. Corrections happen via reversing journal entries (a new entry that exactly offsets the original, then a correct entry). This is a non-negotiable accounting principle. **Data owned:** `chart_of_accounts`, `journal_entries`, `journal_entry_lines`, `accounting_periods` --- ### 6. Ticketing and Job Order Workflow These two are closely related but have distinct responsibilities. | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Ticket System | Receiving client issues (from client portal or staff manual entry), ticket lifecycle (open → assigned → resolved → closed), priority, category | Job Order module (a ticket can spawn one or more job orders), Subscriber module (read subscriber data), Notification module | | Job Order Module | Technical work orders created from tickets, technician assignment, scheduling, completion recording, job type tracking | Ticket system (reads parent ticket), Technician module (assignment), Inventory module (parts used), Accounting module (record labor cost as journal entry) | **Flow:** ``` Client submits ticket (portal) OR staff creates ticket (from call/text) → Ticket is open → Staff reviews → creates Job Order from ticket → Job Order assigned to technician → Technician completes job → marks completed (optionally with parts used) → If parts used → Inventory module decrements stock → Technician compensation calculated → Accounting: debit Labor Expense, credit Technician Payable → Ticket marked resolved ``` --- ### 7. Technician Management | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Technician Module | Technician profiles, compensation model (per-job vs salary), job rate lookup by job type, compensation calculation, payroll summary | Job Order module (receives completion events), Accounting module (writes compensation journal entries) | **Data owned:** `technicians`, `job_types`, `job_rates`, `technician_compensation_records` **Two compensation models must coexist:** - Per-job: each job order completion calculates `job_rate * completion` and credits technician payable - Monthly salary: fixed monthly amount, booked at period end regardless of job count --- ### 8. Inventory Module Manages two distinct concerns: stock on hand and deployed assets. | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Stock Management | Items in warehouse/storage, quantity tracking, purchase recording, low-stock alerts | Expense/Accounting module (purchase creates inventory asset entry), Job Order module (receives depletion events) | | Asset Management | Equipment deployed to subscribers (router serial, ONU serial, location), asset lifecycle (deployed → returned → re-stocked or written off) | Subscriber module (asset linked to subscriber), Job Order module (technician records what was installed) | **Data owned:** `inventory_items`, `inventory_categories`, `stock_transactions`, `deployed_assets`, `asset_assignments` **Key distinction:** - An item is "stock" until it is deployed to a subscriber address - Once deployed, it becomes a "deployed asset" tracked per subscriber - Return = asset moves back to stock (if functional) or written off (if damaged) --- ### 9. Expense Tracking | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Expense Module | Recording non-inventory operational expenses, category assignment, expense approval (optional), attachment of receipts | Accounting module (each expense creates a journal entry: debit Expense account, credit Cash or Payable) | **Data owned:** `expenses`, `expense_categories` --- ### 10. Dashboard and Reporting | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Dashboard Service | Aggregating KPIs: total subscribers, active/suspended, monthly revenue, outstanding receivables, collector balances, open tickets, pending job orders | Read-only from all modules | | Report Engine | Formal financial reports (Balance Sheet, Income Statement, Trial Balance), operational reports (collector reconciliation, technician productivity, inventory valuation) | Read-only from journal entries and operational tables | **Key rule:** The Dashboard and Report Engine are read-only. They never write. All their data comes from the operational modules. Complex reports should be pre-computed on a schedule (via background jobs) rather than computed on-demand to avoid slow queries on large datasets. --- ### 11. Client Portal | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Client Portal | Subscriber-facing view: view current bill, payment history, submit support ticket, view service plan, view deployed equipment | Billing engine (read invoices), Payment Tracker (read payment history), Ticket system (create/read own tickets), Subscriber module (read own profile) | **Key boundary:** The Client Portal is READ-HEAVY with one write action (submit ticket, initiate online payment). It is scoped strictly to the authenticated subscriber — a client JWT cannot access any other subscriber's data. This is enforced at both the permission guard layer AND the data access layer. --- ### 12. Notification Module (Internal) | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | Notification Module | In-app notifications for staff (new ticket, job order assigned, overdue invoices), optionally email later | Receives events from Billing engine, Ticket system, Job Order module | **Scope:** v1 should be in-app only (database-backed notifications, polled or WebSocket-pushed). Email/SMS integration is post-v1. --- ## Data Flow ### Money Flow (Complete Picture) ``` [Service Activation] Billing Engine generates Invoice → Invoice record created (status: unpaid) → Journal Entry: DR Accounts Receivable / CR Subscription Revenue [Cash Payment via Collector] Collector records payment → Payment record created (linked to invoice + collector) → Invoice status updated (partial/paid) → Journal Entry: DR Cash in Transit (collector) / CR Accounts Receivable [Collector Remits to Office] Remittance recorded → Journal Entry: DR Cash (office) / CR Cash in Transit (collector) → Collector balance zeroed [Online Payment - future gateway] Payment gateway webhook → Payment Tracker → Same flow as cash minus collector step [Expense Recorded] Expense Module creates expense → Journal Entry: DR Expense Account / CR Cash or Accounts Payable [Inventory Purchase] Stock purchase recorded → Journal Entry: DR Inventory Asset / CR Cash or Accounts Payable [Equipment Deployed (Capitalized)] Asset assigned to subscriber via Job Order → Journal Entry: DR Fixed Assets (deployed) / CR Inventory [Technician Compensation (Per Job)] Job Order completed → Journal Entry: DR Labor Expense / CR Technician Payable [Technician Paid] Payroll disbursement recorded → Journal Entry: DR Technician Payable / CR Cash ``` ### Ticket-to-Resolution Flow ``` Client Portal or Staff Input → Ticket Created (open) → Staff Reviews → Job Order Created from Ticket → Technician Assigned (notification sent) → Technician Completes Work → Parts Used? → Inventory depleted → Job compensation calculated → Job Order Closed → Ticket Resolved → Client notified (in-app) ``` ### Tenant Isolation Flow ``` Request arrives → DNS resolves subdomain (e.g., acme.netforge.app) → Tenant Resolver: subdomain → tenant_id (cached in Redis) → Auth validates JWT → extracts user_id, tenant_id, role → Request context carries { tenant_id, user_id, role } → Every repository method receives tenant_id as first parameter → All queries include WHERE tenant_id = ? as baseline → PostgreSQL Row Level Security as second defense layer ``` --- ## Suggested Build Order (Phase Dependencies) This ordering reflects hard dependencies — later components cannot function without earlier ones. ### Tier 1: Foundation (Must Build First) These components have no dependencies on other domain modules. 1. **Tenant provisioning + Auth** — Without this, nothing is isolated or authenticated. All other modules receive tenant context from here. 2. **Chart of Accounts (Accounting Module — schema only)** — Every money event needs accounts to post to. Provision default accounts at tenant signup. 3. **Subscriber Management** — The core entity. Billing, inventory, and ticketing all reference subscribers. ### Tier 2: Core Operations (Depends on Tier 1) 4. **Billing Engine** — Depends on subscribers and service plans. Generates invoices. 5. **Payment Tracker** — Depends on invoices from Billing Engine. Records what comes in. 6. **Journal Entry Service** — Depends on Chart of Accounts. Now that billing and payments exist, we can post entries. Build this as a service that Billing and Payment Tracker call. ### Tier 3: Operational Modules (Depends on Tier 1 + 2) 7. **Inventory Module (Stock)** — Depends on Accounting (inventory purchases create journal entries). 8. **Expense Tracking** — Depends on Accounting (expense journal entries). 9. **Ticketing System** — Depends on Subscribers (tickets belong to subscribers). 10. **Job Order Module** — Depends on Tickets, Technician Module, Inventory (parts), Accounting (labor cost). 11. **Technician Management** — Can be built alongside Job Orders; they depend on each other. ### Tier 4: Visibility and Access (Depends on Tier 2 + 3) 12. **Dashboard and Metrics** — Depends on all operational modules being populated with data. 13. **Report Engine (Financial)** — Depends on Journal Entry Service having real data. 14. **Client Portal** — Depends on Billing Engine, Payment Tracker, Ticketing System. ### Tier 5: Enhancements 15. **Notification Module** — Can be added incrementally. Start with in-app, extend to email later. 16. **Asset Management** — Extends Inventory; tracks deployed equipment per subscriber. 17. **Collector Remittance Workflow** — Extends Payment Tracker. --- ## Architecture Decisions ### Decision 1: Shared Database with Row-Level Security **What:** All tenants in one PostgreSQL database. Every table has `tenant_id`. PostgreSQL Row Level Security (RLS) policies enforce isolation at the database level as a backstop. **Why:** Simpler to operate than per-tenant databases. Schema migrations run once. Connection pooling works. For SMB SaaS targeting small ISPs (not enterprise), this is the standard and correct approach. **Tradeoff:** A noisy tenant (one ISP with 10,000 subscribers generating lots of queries) can affect others. Mitigation: rate limiting at the API layer, background job queuing. **Confidence:** HIGH — this is the established pattern for SMB SaaS (used by Shopify, Linear, many others at this scale tier). ### Decision 2: Event-Driven Internally, Not a Message Bus **What:** Modules communicate through in-process function/service calls, not a message queue. When the Billing Engine generates an invoice, it calls `AccountingService.postEntry(...)` directly. No Kafka, no RabbitMQ. **Why:** A message bus is justified when modules are deployed separately (microservices) or when processing is async by requirement. For a monolith targeting sub-second response times on a small-medium dataset, synchronous in-process calls are simpler and correct. Async background jobs (via a job queue like BullMQ or pg-based queue) handle scheduled billing runs and report generation. **Tradeoff:** If one module's accounting write fails, the whole transaction fails. This is actually correct behavior for financial data — you don't want a payment recorded without its journal entry. **Confidence:** HIGH — this is the correct tradeoff for this scale. ### Decision 3: Double-Entry Enforced at Service Layer **What:** The `JournalEntryService` is the only way to write to `journal_entry_lines`. It validates that debits = credits before persisting. No module writes directly to accounting tables. **Why:** Prevents imbalanced books. A bug in the Payment Tracker cannot create an unbalanced journal entry if all writes go through a single service that enforces balance. **Confidence:** HIGH — standard accounting software pattern. ### Decision 4: Immutable Financial Records **What:** Payments and journal entries cannot be edited after creation. Corrections use reversal entries. **Why:** Audit trail integrity. Tax compliance. Debugging is possible. Financial regulators (even informal ones like business owners doing year-end) need to see the full history of what happened, not what it looks like after edits. **Confidence:** HIGH — non-negotiable for any accounting system. ### Decision 5: Collector Balance as Derived State **What:** A collector's current cash balance is not stored as a single field. It is computed from: `SUM(payments.amount WHERE collector_id = X AND NOT remitted) - SUM(remittances.amount WHERE collector_id = X)`. This can be cached in Redis for performance. **Why:** A stored balance field is prone to drift (bugs, race conditions). The authoritative answer always comes from the transaction log. **Tradeoff:** Slightly more expensive to compute. Cache with a short TTL (or invalidate on new payment/remittance) to handle at scale. **Confidence:** HIGH — event sourcing / derived state principle. --- ## Anti-Patterns to Avoid ### Anti-Pattern 1: Application-Layer-Only Tenant Isolation **What:** Relying solely on `WHERE tenant_id = ?` in application code, without any database-level backstop. **Why bad:** A single bug (missing WHERE clause) leaks data across tenants. This is a catastrophic security failure for a SaaS product. **Instead:** Add PostgreSQL Row Level Security as a second layer. Even if the application omits the tenant filter, RLS prevents the query from returning another tenant's rows. Set RLS on every table that contains tenant data. ### Anti-Pattern 2: Billing Engine Writing Directly to Cash Tables **What:** The Billing Engine or Payment Tracker directly incrementing/decrementing account balances in a "balances" table. **Why bad:** Balance tables drift. Concurrent updates cause race conditions. You lose the transaction history needed for reconciliation. **Instead:** No balance table exists. Balances are always derived from journal entry lines. `SUM(amount WHERE account_id = X AND type = 'debit') - SUM(amount WHERE account_id = X AND type = 'credit')` IS the balance. Cache for performance, but the source of truth is the ledger. ### Anti-Pattern 3: Merging Ticket and Job Order into One Entity **What:** Treating "ticket" and "job order" as the same record. **Why bad:** A single client issue might spawn multiple job orders (initial visit + follow-up). A job order might exist without a client ticket (proactive maintenance). They have different lifecycles, different owners (client-facing vs technician-facing), and different data shapes. **Instead:** Keep them separate. Tickets have a `has_many :job_orders` relationship. Job orders have `belongs_to :ticket` (nullable for non-ticket-originating work). ### Anti-Pattern 4: Storing Computed Financial State **What:** Storing "total revenue this month" or "outstanding balance per subscriber" as columns that get updated on every transaction. **Why bad:** Race conditions. Bugs cause silent drift. Hard to audit. Hard to debug when numbers don't match. **Instead:** Compute on demand from the ledger. Cache aggressively in Redis with appropriate invalidation. For reports, use materialized views or pre-computed report snapshots run on a schedule. ### Anti-Pattern 5: Granting Client Role DB-Level SELECT on All Tables **What:** Building RBAC only at the API route level, without data-access-layer scoping for the client role. **Why bad:** A client who discovers an API endpoint (through network inspection) can potentially access other subscribers' invoices if the data layer doesn't enforce subscriber-scoping on top of tenant-scoping. **Instead:** Client Portal requests are scoped at TWO levels: tenant_id (from JWT) AND subscriber_id (from JWT). The data access layer for all client-facing queries adds `AND subscriber_id = ?` in addition to `AND tenant_id = ?`. ### Anti-Pattern 6: Soft Deletes Without Accounting for Them **What:** Adding `deleted_at` soft deletes everywhere without ensuring all queries include `WHERE deleted_at IS NULL`. **Why bad:** Deleted records silently appear in reports, counts, and joins if any query forgets the filter. **Instead:** Use a database view or ORM default scope that filters deleted records. Alternatively, use PostgreSQL RLS to filter them. Never rely on developers remembering to add the filter manually. --- ## Scalability Considerations | Concern | At 10 tenants (MVP) | At 100 tenants | At 1,000 tenants | |---------|--------------------|-----------------|--------------------| | Database size | Single PostgreSQL, no concerns | Same, add indexes | Consider read replicas for reports | | Billing runs | Cron job, sequential per tenant | Background job queue with tenant workers | Distributed job queue (BullMQ), parallelized | | Report generation | On-demand from ledger | Nightly pre-computed snapshots | Materialized views, async report jobs | | Tenant isolation | RLS policies | Same | Same, plus connection pooling limits per tenant | | API performance | No concerns | Cache hot reads in Redis | Horizontal API scaling, DB read replicas | | Concurrent collectors | Not a concern | Optimistic locking on payment records | Same | --- ## Sources All findings based on established SaaS architecture patterns, double-entry accounting principles, and multi-tenant database design patterns. External verification tools were unavailable during this research session. **Confidence notes by section:** - Multi-tenant shared DB with RLS: HIGH (industry standard, well-documented) - Double-entry accounting patterns: HIGH (GAAP principles, stable for decades) - Modular monolith over microservices for this scale: HIGH (well-established guidance from Martin Fowler, Sam Newman, others) - Billing engine prepaid/postpaid model: HIGH (standard ISP billing concepts) - Collector balance as derived state: HIGH (event sourcing principle) - Specific framework/library choices: Deferred to STACK.md