Skip to content

Dependency injection patterns

How @vibrant/lib and its consuming apps structure swappable dependencies. Each pattern below links to the real file that best demonstrates it.

1. Provider interfaces

Behavior is defined by an interface in providers/interfaces/**; concrete implementations live in providers/. Consumers depend on the interface type, never the concrete class.

  • Interface: packages/lib/src/providers/interfaces/auth/IUsersProvider.ts
  • Implementations: packages/lib/src/providers/PGUsersProvider.ts, packages/lib/src/providers/KeycloakUsersProvider.ts

2. Factories for swappable implementations

A factory function picks and assembles the right implementation(s) behind an interface return type, so callers never construct concrete classes directly.

  • createApiService() in packages/lib/src/services/api/APIFactory.ts builds an IAPIService from AUTH_PROVIDER/API_PROVIDER config. When auth and data providers differ, it composes them with CompositeUsersProvider / CompositeApiService rather than hard-coding one provider pair.
  • createGatewaySetupClient() in packages/lib/src/services/gateway/GatewaySetupClientFactory.ts picks a BLE, REST, or mock IGatewaySetupClient from a GatewayConnection.kind.

3. Constructor injection for the PostgREST transport

Providers take their transport client as an optional constructor parameter, defaulting to the real singleton. This is what makes them unit-testable and lets a provider be pointed at a different client (e.g. cloudPostgrest instead of the gateway-routing postgrest) without changing its code.

class PGDevicesProvider implements IDevicesProvider {
  constructor(
    private readonly db: PostgrestDataClient = postgrest,
    private readonly cloudDb: PostgrestDataClient = cloudPostgrest,
  ) {}
}

See packages/lib/src/providers/PGDevicesProvider.ts and the PostgrestDataClient / PostgrestAuthClient / PostgrestLike / PostgrestCloudClient interfaces in packages/lib/src/utils/postgrest.ts that describe the minimal surface each provider actually needs.

Tests inject a fake instead of the real client:

const fakeDb: PostgrestDataClient = { get: jest.fn(), post: jest.fn(), ... };
const provider = new PGDevicesProvider(fakeDb);

Full example: packages/lib/src/providers/__tests__/PGDevicesProvider.test.ts. Run it with npm run test --workspace @vibrant/lib.

4. App bootstrap through exported functions, not singletons

Apps configure @vibrant/lib by calling its exported functions (createApiService, configureApiEndpoints), never by importing an internal singleton and calling its mutator methods directly.

  • configureApiEndpoints() in packages/lib/src/utils/postgrest.ts is the one seam for setting the cloud PostgREST base/auth URLs at startup.
  • apps/partner/App.tsx calls it during initializePartnerEnvironment() instead of reaching into cloudPostgrest.setBaseUrl(...) directly.

5. Mocks/fakes through the same factory seam

A fake implementation is added as another branch of the existing factory, not as a separate ad hoc code path the app has to select manually.

  • MockGatewaySetupClient (packages/lib/src/services/gateway/MockGatewaySetupClient.ts) implements IGatewaySetupClient and is selected via createGatewaySetupClient({ kind: "mock" }) -- the same call callers use for BLE/REST. App-specific mock detection (env var, isMockDeviceSetupEnabled()) stays in apps/partner/app/services/deviceSetup/mockDeviceSetup.ts, since that policy is app-specific; the swappable implementation itself lives in packages/lib next to the real adapters.

6. Public package surface vs. optional native peer deps

@vibrant/lib's barrel (packages/lib/src/index.ts) re-exports BLE, firmware, and social-auth modules that require optional native peer dependencies (react-native-ble-plx, @playerdata/react-native-mcu-manager, react-native-zeroconf, etc.). If an app imports the barrel without those packages installed, Metro fails to resolve them at bundle time -- even for code paths the app never calls, since Metro's dependency graph is built statically.

Apps that don't have every optional peer dep installed use deep imports instead of the barrel, e.g. @vibrant/lib/src/services/api/APIFactory instead of @vibrant/lib. See apps/partner/App.tsx and apps/partner/app/services/api/APIFactory.ts. packages/lib/src/types/native-modules.d.ts provides ambient type shims so typecheck stays green without installing every optional peer dependency.

Rule of thumb: before wiring a new shared factory branch into an app, confirm the app has that branch's native peer deps installed. GatewaySetupClientFactory.ts imports BLE code unconditionally at module scope (via GatewaySetupBLEClient.ts -> VibrantBLEClient.ts), so any import from that file -- even just to reach the REST or mock branch -- requires react-native-ble-plx to be installed in the importing app. apps/partner/package.json added it for exactly this reason when wiring in the mock gateway client.

Known gaps

Provider interfaces (e.g. IDevicesProvider) still return any for several payload/return types. The dependency-inversion boundary is correct, but typed payloads would make it easier to keep implementations honest. Not addressed in this pass.