Modular Plugin Layer, Testable by Design
Cross-platform apps move fast, until native features start leaking into product code. BLE streaming, notifications, file access, analytics, all must behave consistently across iOS and Android without tangling UI logic with platform quirks or slowing tests to a crawl. Our current setup uses direct native bridges from a view-model into platform entry points for analytics and BLE. That choice shipped value quickly and kept incident response nimble, but it also makes isolation, swapping, and automation harder than they should be.
This post lays out a realistic blueprint to evolve that foundation into a modular, testable plugin layer without a big-bang rewrite. We'll keep delivery speed, carve crisp seams, and layer in observability and tests, step by step. At Hoomanely the guiding principle is simple: product code should depend on capabilities, not operating systems.
Problem
Today's trade-offs are effective but limiting. Direct bridges in feature logic, native calls for analytics and BLE streaming live close to a view-model and platform entry points rather than dedicated packages, fast to ship and debug but with platform details bleeding upward and swapping being difficult. DI that stops short of the platform, a service locator wires high-level services yet platform bridges remain concrete, constraining test doubles and runtime swaps. Sparse automation, one integration test and one unit test exist, enough for smoke checks, not enough for regression confidence. Log-first observability, readable logs help but there are no structured metrics or SLIs to quantify latency and success rates. Versioning via app releases, native behavior changes ship only with the whole app, independent rollbacks aren't possible.
Where this pinches: platform quirks creep upward, analytics and BLE handshakes are harder to mock, and adding reconnect or background behaviors risks spreading OS-specific assumptions across features.
Approach
The goal is a federated, modular plugin layer, delivered incrementally. Contracts first, channels later, introduce small capability contracts like an analytics sink, a BLE session, or a notifier, hiding bridge details behind adapters implementing those contracts. Keep DI, formalize the seam, continue using the locator but register interfaces instead of concrete types, current adapters can keep calling the bridges under the hood. Observability at the boundary, emit structured, low-cardinality metrics at capability entry and exit while keeping human-readable logs for forensics.
Layered testing has three tiers: unit tests with fakes (fast, pure Dart), contract tests per platform (behavior parity), and a tiny, deterministic end-to-end set (confidence without flake). Versionable boundaries come later, when contracts stabilize, split them into platform-interface and per-OS implementations following semantic versioning. This plan preserves today's velocity while creating the seams needed for tomorrow's modularity.



Process
BLE streaming handshake and monitoring uses a single orchestrator coordinating connect, discover, subscribe, with timers for idle detection and backoff on transient errors. Centralization reduced regressions and made incident triage sane. Notification permissions have a view-model check status, trigger the OS prompt via a bridge, and record outcomes for UX follow-ups. Post-frame initialization runs early capability checks, notification channels, adapter state watchers, analytics boot, after the first frame to prevent startup jank. Analytics events go through an app-level sink forwarding events through a native bridge with lightweight retries and error reporting.
A quiet improvement already implemented: thin adapters around the bridges unify call sites today and create the future seam for inversion.
What we tightened without rewrites: guarded transitions fence the handshake to prevent re-entrancy and race conditions during state transitions. One place for timers standardizes connection monitoring cadence to avoid drift between features. Locator-registered services route cross-feature access through the locator, removing scattered bridge calls. Capability contracts, introduced incrementally, add minimal interfaces for analytics, BLE session, and notifications while adapters continue using current bridges underneath. Structured telemetry at boundaries adds counters and timers such as handshake start/success/error, event enqueue duration, and permission prompt result. And connection monitor refinement consolidates backoff and health checks into a single helper to keep reconnect logic consistent across flows.
Suggestions for the next steps: move platform details fully behind adapters, so callers use capability contracts while channel names, arguments, and parsing live in adapters only. Add contract tests per platform, a shared Dart test suite validating timeouts, error mapping, and stream semantics on both iOS and Android. Build a small, reliable end-to-end suite, two or three seeded flows like pair-and-stream or capture-and-upload running deterministically. Add SLIs and dashboards for p95 time-to-first-value, reconnect success within N attempts, and analytics enqueue latency and error rate, with conservative, actionable alerts. And introduce versionable packages when stable, splitting contracts and implementations with feature flags for safe, fast rollouts and reversions.
Results
Speed without chaos: direct bridges enabled fast delivery of BLE streaming and analytics, central orchestration and standardized timers contained complexity. Cleaner seams: thin adapters reduced duplicate call sites and paved an easy path to inversion behind interfaces. Smoother startup: post-frame initialization kept the first render responsive while still preparing observers and capability state. Better incident posture: consistent logs around handshake order, permission flows, and key timing points shortened triage.
The compounding benefit: the team increasingly thinks in capabilities, not OS APIs. That mindset makes future extraction safe and predictable.

Observability improvements
Currently, clear human-readable logs exist at key steps on both sides of the bridge. Improvements include structured events with durations and outcome enums at capability boundaries, low-cardinality tags (OS, app version, feature flag) for stable dashboards, and SLIs with conservative alerts, P95 time-to-ready, reconnect success within N attempts, and analytics enqueue latency and error rate.
Testing and versioning strategy
Broaden unit tests with fakes for capability contracts, add contract tests running on both platforms, and keep end-to-end tests small, seeded, and deterministic. Interfaces and platform implementations become separately versioned packages, contract changes get made deliberately, implementations ship fixes independently, and feature flags at call sites enable staged rollouts and quick reverts.
Real scenarios
Streaming handshake coordination: connect, discover, subscribe, guarded against overlap and tuned with backoff. Connection health: heartbeats, idle detection, and retry cadence from a single source of truth. Permission UX: status, rationale, prompt, follow-up actions, with outcomes recorded for UX improvements. Startup hygiene: essential observers and validation deferred until after the first frame to keep the app responsive.
Notes to remember
Draw the boundary first, introducing capability contracts before moving any code, adapters can keep calling existing bridges under the hood while the app talks only to interfaces. Centralize lifecycles, keeping handshake, timers, and reconnect logic in one orchestrator, guarding transitions to prevent re-entrancy and race conditions. Normalize async semantics, futures for one-shots and streams for continuous data, always including timeouts and cancellation paths. Instrument the seams, turning key boundary steps into measurable events. Prefer small, reliable tests over many flaky ones. Keep DI simple but meaningful, registering interfaces in the locator now and swapping implementations later without touching the UI. Feature-flag risky paths so you can roll forward and back without UI changes. Delay package splits until stable, extracting federated packages only when contracts stop churning. Document error taxonomy, distinguishing user-denied, timeout, transient I/O, and unknown errors, routing each to clear UX or retry behavior. And protect the first frame, deferring heavy capability checks post-frame and surfacing actionable state changes to the UI when ready.
Hoomanely builds pet-health experiences spanning wearables, smart feeders, and a mobile companion app. A modular plugin layer lets us evolve BLE, notifications, and analytics independently, test them reliably, and keep the customer experience predictable as native stacks evolve.
Key takeaways
You don't need a rewrite to gain modularity, draw the seams now and extract when stable. Make product code depend on capabilities, not OS details. Test in layers, fast unit tests, parity-keeping contract tests, and a tiny end-to-end set. Promote logs to structured metrics and SLIs so you can see latency and success clearly. And when interfaces settle, version the boundaries so fixes ship faster and roll back safely, without touching the UI.