0001 — Multi-module clean architecture¶
- Status: accepted
- Date: 2026-09-21
- Deciders: BlueLib maintainers
Context¶
Android's Bluetooth surface changed in every release from 5.0 to 17, and it is full of behaviour that cannot be unit tested without a device: status codes delivered inside callbacks, one-outstanding-request limits on ATT, scan quotas enforced by the platform, payload budgets that are truncated rather than rejected.
A single-module library would put that behaviour next to android.bluetooth calls, where it can only be
exercised by an emulator or a shelf of real devices.
Decision¶
Split the library into modules along the dependency rule:
bluelib-core— domain, validation, policies, state machines, ports, error taxonomy. Pure Kotlin, and the build fails if it ever importsandroid.*orandroidx.*.bluelib-android— the adapters that implement the ports withandroid.bluetooth, plus the permission gateway, capability probes and the platform dispatcher.bluelib— theBlueLibfacade that wires the adapters and exposes a stable surface.bluelib-testing— fakes for every port.
Consequences¶
Good
- Policies and state machines are covered by fast JVM tests; the GATT server's answer logic is tested
without a device because
GattServerStateholds the attribute state with no Android types. - The compatibility story is centralised:
ApiLevelandPlatformCapabilitiesare the only places the SDK level matters, and they are small enough to review as a whole. - Applications can test their own Bluetooth feature with
bluelib-testingand no hardware. - A future non-Android target reuses
bluelib-coreunchanged.
Bad
- More Gradle plumbing, more modules to publish, and three artifacts where a competitor ships one.
Mitigated by a single dependency:
bluelibbrings the others transitively. - Some types are
publiconly because Gradle modules cannot shareinternalvisibility. Those carry the@BlueLibInternalopt-in annotation so the intent is explicit and applications are steered to the facade.
Alternatives considered¶
- One module with
internalvisibility. Rejected:internaldoes not cross module boundaries, so the facade could not be a separate artifact or the platform layer would have to be public anyway — while still being untestable in isolation. - Pure reflection-based wrapper. Rejected as brittle and liable to be blocked by future Android releases; the whole point is to use the public SDK surface correctly.