Design Decisions
Key design constraints, architectural rationale, trade-offs, and extensibility guide for the SPA architecture.
Design Decisions & Architecture
Key Design Decisions & Constraints
SPA via CrelioDashboard vs. Micro-Frontends
Decision: Unify all staff-facing modules into a single CrelioDashboard React application rather than using Module Federation or a micro-frontend architecture.
Why: The modules share ~80% of their dependencies (React, Redux, AG Grid, i18n, reactstrap, axios) and all operate within the same staff session context. Module Federation would introduce:
- Runtime dependency resolution overhead
- Version skew risks across independently deployed modules
- Complex shared state management (each micro-frontend would need its own Redux store or a shared event bus)
- Additional CI/CD complexity (each module deployed independently)
Since all SPA-eligible modules (Registration, Accession, Operations, Finance, Admin, LabAdmin, Inventory, CRM Dashboard) are in the same repository (livehealth-frontend) and share the same release cadence, a monolithic SPA with code-splitting per module gives the same lazy-loading benefits without federation overhead.
Trade-off: All modules must be built and deployed together. A change to the Finance module requires rebuilding the entire SPA. This is acceptable because the team already follows a single-branch, unified release model.
Hash-Based Routing (/#/) vs. HTML5 History API
Decision: Use hash-based routing (/crelio-dashboard/#/finance/dashboard) instead of clean URLs (/crelio-dashboard/finance/dashboard).
Why: The frontend is served by Django, which owns URL routing. With HTML5 History API:
- Every frontend route would need a corresponding Django URL pattern pointing to
CrelioDashboardView - Deep-linking would fail if Django didn't recognize the path (404 before React could handle it)
- Every new frontend route addition would require a backend URL config change
With hash routing, Django only needs a single URL pattern (/crelio-dashboard/) — everything after # is handled entirely client-side. This decouples frontend route additions from backend deployments.
Trade-off: URLs are less "clean" and hash fragments don't participate in server-side redirects. This is acceptable because the SPA is an internal tool (not SEO-sensitive) and all navigations happen client-side after initial load.
is_spa_enabled as a Redis Flag vs. Database Column
Decision: Store the SPA enablement flag in Redis (is_spa_enabled_{lab_id}) rather than as a column on the labs or labFeatures table.
Why:
- Performance — the flag is checked on every login and every module-level URL redirect. A Redis lookup is sub-millisecond; a DB query adds latency.
- Rollback speed — if the SPA causes issues for a lab, support can flip the flag instantly via Redis without touching the DB or deploying code.
- Gradual rollout — new labs can have the flag set automatically at account creation time without requiring a migration.
Trade-off: The flag is not queryable via SQL (can't do SELECT COUNT(*) FROM labs WHERE is_spa_enabled = 1). For admin reporting, support must use the Redis CLI or a dedicated admin endpoint. The flag also lacks audit trail — there's no record of when the flag was changed or by whom.
Implementation:
# labs/cacheFunction.py
def is_spa_enabled(lab_id):
"""
Flag will be used to get/set SPA (Single Page App).
"""
if not lab_id:
return False
cache_key = "is_spa_enabled_{lab_id}".format(lab_id=lab_id)
return cache.get(cache_key) or FalseEager Session Initialization vs. Lazy Per-Module Loading
Decision: Fetch all module settings (operations settings, report settings, department list, doctor list, CRM permissions, registration config, bill settings, inventory settings) during the SPA bootstrap rather than lazily when the user navigates to a module.
Why: In the legacy single-build architecture, each module independently fetched its required settings on mount. If the SPA deferred these fetches too, the first navigation to each module would incur the same delay as the old full-page reload. By front-loading all settings during the initial SPA bootstrap:
- Module switches become instant — no loading spinners, no network delays
- Settings are available in Redux for cross-module features (e.g., a Registration form that checks Operations settings)
Trade-off: The initial SPA load time is longer (more API calls during bootstrap). This is mitigated by:
- Using
Promise.all/axiosInstance.allfor parallel fetching - The bootstrap happens once per session, not on every module switch
- The total initialization time is still less than two legacy module loads combined
SPA_URL_MAPPER: Static Mapping vs. Dynamic URL Rewriting
Decision: Use a hardcoded dictionary (SPA_URL_MAPPER) to map legacy module URLs to SPA hash routes.
SPA_URL_MAPPER = {
"/accountOverview/": "/crelio-dashboard/#/admin/account-overview",
"/billing/#directRegistration": "/crelio-dashboard/#/registration/",
"/waitingList/": "/crelio-dashboard/#/operation",
"/finance-new/#/dashboard": "/crelio-dashboard/#/finance/",
"/sample-accession/": "/crelio-dashboard/#/accession/",
"/inventory-v5/": "/crelio-dashboard/#/inventory",
"/crm/dashboard/": "/crelio-dashboard/#/crm",
"/referral-management/": "/crelio-dashboard/#/finance/referral-management/referral-list",
"/adminTestList-v5/#/admin/account-overview": "/crelio-dashboard/#/admin/account-overview"
}Why: The mapping is finite and well-known. There are exactly 9 legacy module URLs that need mapping. A regex-based or algorithmic approach would be over-engineered and harder to debug. The static dictionary:
- Is self-documenting — you can see every mapping at a glance
- Is easy to extend — adding a new mapping is a one-line change
- Has zero runtime parsing cost
Constraint: If a new module URL is added without updating SPA_URL_MAPPER, it will fall through to the legacy URL, which will cause a full-page reload instead of SPA navigation.
Module-Aware Sidebar via URL Path Matching
Decision: Determine the active module from the URL path rather than maintaining explicit module-selection state in Redux.
const matchingModule = useMemo(() => {
const pathParts = location.pathname.split("/").filter(Boolean);
const moduleName = pathParts[0]?.toLowerCase();
const moduleByRoute = modules.find((module) =>
module.menu.some((item) => {
const itemRouteBase = item.route?.split("/").filter(Boolean)[0]?.toLowerCase();
return location.pathname === item.route || pathParts[0]?.toLowerCase() === itemRouteBase;
})
);
const moduleByName = modules.find(
(module) => module.selectedModule?.toLowerCase() === moduleName
);
return moduleByRoute || moduleByName || defaultModule;
}, [location.pathname, modules]);Why: This makes the sidebar purely reactive to the URL. If a user deep-links to /finance/referral-management/referral-list, the sidebar correctly shows Finance menus without any explicit "select Finance" action. This also means browser back/forward navigation correctly updates the sidebar.
Trade-off: The resolution logic runs on every route change. With ~7 modules × ~10 menu items each, this is ~70 string comparisons per navigation — negligible.
Architectural Rationale
Why Keep Both SPA and Single-Build?
The dual-mode architecture exists for a critical reason: backward compatibility and safe migration.
| Concern | Why Both Modes |
|---|---|
| Gradual rollout | Labs have different configurations, customizations, and user workflows. A big-bang migration is risky. |
| Rollback safety | If the SPA has a bug affecting a specific lab's workflow, support can disable it instantly without a deployment. |
| CI/CD | Jenkins builds both modes in the same pipeline. If a single-build regression is found, the fix works for both. |
| Testing | QA can validate both modes in parallel before enabling SPA for a customer. |
Why Hash Routing Instead of Migrating to React Router v6 + BrowserRouter?
The codebase uses react-router-dom@5.3.4 with HashRouter semantics (via hash fragments in useLocation). Migrating to BrowserRouter in React Router v6 would require:
- Django URL patterns for every frontend route (or a catch-all
re_path(r'^crelio-dashboard/.*$', ...)) - React Router v5 → v6 migration (breaking API changes:
Switch→Routes,component→element, removedexact) - Testing every deep-link, bookmark, and shared URL
This migration is planned but deferred to avoid coupling it with the SPA rollout.
Known Constraints & Gotchas
| Constraint | Impact | Mitigation |
|---|---|---|
| Hash-based routing | URLs contain /#/ which is unusual for users sharing links | Planned migration to HTML5 History API in a future phase |
| All modules loaded eagerly | SPA bootstrap fetches settings for all modules, even if the user only uses Registration | React.lazy() + code splitting ensures only the active module's components are loaded; settings APIs are lightweight |
| Redis flag has no audit trail | Cannot track when is_spa_enabled was changed or by whom | Wrap flag changes in a support dashboard action that logs to crelio_data_migrations |
| SPA_URL_MAPPER must be kept in sync | New legacy URLs won't redirect to SPA if not added to the mapper | The mapper is in decorators/utils.py — easy to grep and update |
| Support users always get SPA | Support login bypasses the is_spa_enabled check | This is intentional for faster debugging, but means support may see different behavior than the lab they're supporting if that lab has SPA disabled |
| Standalone modules don't benefit | DoctorLogin, ReferralLogin, OrganisationLogin remain separate full-page apps | These serve different user roles with different sessions — unifying them would require a multi-session architecture |
| No server-side rendering | Initial load depends on client-side React bootstrap | Acceptable for internal tool; mitigated by loading spinner in index.html |
Extensibility Guide
| What you want to do | How to do it |
|---|---|
| Add a new module to the SPA | 1. Create a useXxxRoutes() and useXxxSidebarMenu() hook in CrelioDashboard/RoutesAndMenus/ 2. Register them in routes.tsx's modules array 3. Add the legacy URL → SPA URL mapping in SPA_URL_MAPPER 4. Update the Django login flow to redirect to the SPA URL when is_spa_enabled |
| Enable SPA for a lab | Set is_spa_enabled_{lab_id} to True in Redis |
| Disable SPA for a lab | Delete the is_spa_enabled_{lab_id} key from Redis (or set to False) |
| Add a new route to an existing module | Add a CrelioRoute entry in the module's *RoutesAndMenu file. No backend changes needed. |
| Add a new sidebar item | Add a CrelioSidebarItem entry in the module's useXxxSidebarMenu() hook |
| Migrate to HTML5 History API | 1. Add a Django catch-all for /crelio-dashboard/* 2. Switch from hash-based useLocation to BrowserRouter 3. Update all internal links to remove /#/ 4. Update SPA_URL_MAPPER to remove hash fragments |