Pharmacy operations don't stop because the internet does. A pharmacist in Accra processing a sale at 9 AM on a morning when the router is down cannot tell the customer to come back later. She needs the sale to go through, the inventory to update, and the receipt to print — right now — and she needs the system to figure out the server sync on its own when connectivity returns.
That's the core constraint Bloom Desktop was built around. It's an offline-first pharmacy point-of-sale shipped as an Electron desktop app, powering mPharma-connected pharmacies across multiple African markets. This is how it's put together.
The first architectural decision was that Bloom Desktop had to run two ways: as a native Electron app on pharmacy workstations, and as a web app in a browser for environments where installing software isn't possible. Same React codebase, same component tree, same business logic — but a completely different runtime underneath.
We solved this with a platform adapter layer. At the top level sits a DesktopAdapter abstract class that defines the contract for interacting with the host environment: file I/O, window management, platform detection. ElectronAdapter implements it for Electron; BrowserAdapter falls back to browser-safe equivalents. The same pattern repeats for the database layer — DatabaseAdapter is abstract, ElectronDatabaseAdapter calls through IPC to the main process, BrowserDatabaseAdapter uses Dexie (IndexedDB) to handle the same operations in a browser context.
The router uses HashRouter instead of BrowserRouter throughout, which is the kind of small decision you don't think twice about until you're debugging a packaged Electron app that can't serve history-based routes from file://.
In Electron, your UI runs in a sandboxed renderer process and your Node.js code runs in the main process. They can't share memory. Everything crosses a structured message-passing boundary via IPC.
This is actually a feature for an app like this. It forces a clean separation: the React layer never touches the database directly. Every database operation is a named IPC channel — db:create-sale, db:get-unsynced-patients, db:mark-sale-as-synced — implemented in the main process and called through a typed adapter in the renderer. contextIsolation: true is enabled, which means the preload script is the only surface area between the two worlds.
The Electron main process handles database connection, migration orchestration, PIN security storage (using Electron's safeStorage for OS-level encryption), and file system access. The React renderer handles everything the user sees and nothing else.
The IPC boundary is the architecture. Once you treat it that way instead of as overhead to route around, the rest of the design follows naturally.
Pharmacies have different infrastructure realities. Some run a single workstation with a local SQLite file and no server. Others are part of a chain with a shared PostgreSQL instance synced across branches. Bloom Desktop supports both from a single codebase.
Prisma handles both backends, but not with a single schema — SQLite and PostgreSQL have enough type system differences (array fields, case-insensitive string matching, datetime handling) that we maintain two separate Prisma schemas: schema.prisma for SQLite and schema.postgresql.prisma for PostgreSQL. Both generate their own client into separate directories. The database layer picks the right client at runtime based on the stored configuration.
The configuration itself is stored encrypted on disk using safeStorage.encryptString(), which delegates to the OS keychain on macOS and credential store on Windows. The app reads it at startup, tests connectivity, runs any pending migrations, and then hands a connected Prisma client to the handler layer.
Migration handling in packaged Electron apps has one sharp edge: npx prisma migrate deploy isn't available when the app is bundled as an .asar. For packaged apps, we run migrations directly with better-sqlite3 for SQLite and the pg client for PostgreSQL — reading migration SQL files from app.asar.unpacked/prisma/ and executing them ourselves, with our own _prisma_migrations tracking table.
This is where the offline-first constraint shows up most directly.
The sync engine has one job: find every sale or patient record that was created locally while the device was offline, POST it to the mPharma API, and mark it synced. It runs on a background schedule and whenever connectivity is restored.
But "POST it to the API" turns out to have several failure modes worth designing around:
Duplicate UUID conflicts. If a sale was already accepted by the server on a previous sync attempt that timed out before the confirmation came back, the server will reject the retry with a conflict error. The sync engine detects this class of error, regenerates the sale UUID, and retries with the new identifier rather than surfacing an error to the user.
Patient-first ordering. If a sale references a patient created offline, the patient has to be synced to the server before the sale — otherwise the sale references a patient ID that doesn't exist remotely yet. The engine handles patients before sales in every sync cycle, and updates local sale records with the server-assigned patient IDs after the patient sync succeeds.
Retry with backoff. Transient network failures get three retry attempts with a 5-second delay between them. Hard failures (auth errors, malformed payloads) fail immediately and log to sync history for visibility.
Every sync run writes a record to SyncHistory in the local database: start time, end time, records processed, records failed, error detail if any. SyncMonitoringService aggregates these into the sync status the UI surfaces — so pharmacists can see at a glance whether their data is caught up with the server.
The React layer never constructs a service directly. All business logic lives in typed service classes — AuthService, DatabaseService, ProductService, SyncEngine, OutboxService — and ServiceFactory is the singleton that constructs and owns them. A component that needs to check inventory calls useInventoryData(), which calls ServiceFactory.getDatabaseService() internally. The component never knows or cares which database backend is live.
This keeps testing coherent. Services are testable in isolation; components don't need to mock infrastructure. Vitest runs tests sequentially (not in parallel) because they hit real SQLite database connections — we made the deliberate call that test fidelity matters more than test speed, and that a test passing against a mock that diverges from real database behavior is worse than a slower test that actually runs the query.
State management splits cleanly by concern: POS cart state lives in a PosContext managed with useReducer, because it's scoped to a session and doesn't need Redux's overhead. Alert state — the global notification system — lives in Redux Toolkit, because it needs to be accessible from services outside the React tree.
Pharmacy POS systems handle sensitive patient data and financial records. The authentication model reflects that.
Every local user account has a PIN stored as a salted hash in the local database. Logging in doesn't require internet — it's a local credential check that the Electron main process validates through a dedicated IPC handler. This means a pharmacist can unlock the POS from a login screen even when the server is unreachable.
The implementation stores PIN hashes through PinSecurityService, which runs in the main process where it can access safeStorage and the Prisma database connection without exposing either to the renderer layer. A wrong PIN produces a timing-stable comparison to avoid leaking information about whether the user account exists.
The adapter pattern is the right call for cross-platform code, but you pay for it in upfront surface area. Defining the DatabaseAdapter abstract class with 40+ methods before writing any implementation feels like over-engineering until the day you add a new database backend and realize nothing in the application layer needs to change.
The two-Prisma-schema approach is messier than I'd like, but it's honest about a real divergence. SQLite and PostgreSQL are not the same database with a compatibility layer between them — they have meaningfully different capabilities, and pretending otherwise leads to subtle bugs at the worst possible moments. Two schemas, two generated clients, one clearly defined boundary.
The biggest thing offline-first actually changes about development is that you can never assume an operation has a definitive outcome. A sale that returns a 200 might not have actually reached the server. A 408 might mean the request succeeded and the response was lost. The sync engine's duplicate detection and UUID regeneration logic exists entirely because timeouts are not the same as failures, and the database layer needs to be built with that ambiguity in mind from day one.