0002 — Hexagonal architecture for shared business logic
Status: Accepted Date: 2026-04 Deciders: TwoMore mobile team
Context
TwoMore runs on two platforms (Expo mobile, Next.js web) and is developed primarily through agentic workflows. The shared business logic lives in packages/app/src/.
Before this decision was formalised, feature code reached directly into Supabase adapters, Supabase-generated types leaked across package boundaries, and there was no boundary that would let us swap the data layer for tests. As the codebase grew past ~50k LoC, three concrete problems emerged:
- Untestable hooks — presentation hooks imported
supabasedirectly, making unit tests impossible without spinning up a real Supabase instance. - Supabase type bleed —
generated.types.ts(Supabase's auto-generated DB schema) was imported from presentation hooks and feature packages, coupling every consumer to the DB column names. - No swap boundary — to test a use-case hook with fake data, developers had to mock the entire Supabase client object rather than supplying a small in-memory port implementation.
Options considered
Option A: Flat folder per feature
All code for a feature (types, hooks, components, API calls) in one directory.
- Pros: low ceremony, familiar to most RN engineers
- Cons: boundary erosion is invisible until it's catastrophic; at >100k LoC, every hook reaches everywhere; the Supabase client ends up imported from presentation files without anyone noticing
- Effort: 0 (status quo)
- Reversibility: low — accumulated coupling is expensive to undo
Option B: Feature-sliced design (FSD)
Layers by UI concern: shared / entities / features / widgets / pages / app.
- Pros: popular in the React ecosystem; clear slice boundaries
- Cons: FSD is UI-slicing, not I/O-slicing; it doesn't address the swap boundary for external deps; Supabase adapters have no natural home; the domain layer bleeds into
entities/which is allowed to import fromshared/— the I/O boundary is still porous - Effort: ~1 week migration
- Reversibility: medium
Option C: Hexagonal architecture (ports and adapters)
Five layers inside packages/app/src/: domain/ → application/ → presentation/ consume ports/; adapters/ implements ports/. Dependency direction is strictly inward. Registry (registry.ts) wires concrete adapters to port interfaces at startup.
- Pros: unambiguous I/O boundary (ports); swap-for-test works at the port level; Supabase type leakage is mechanically blocked (ARCH-3); agentic contributors can't accidentally reach across layers without triggering a lint/arch-test failure
- Cons: more files per feature (entity + port + adapter + mapper + hook); ramp-up time for engineers unfamiliar with hex arch; every new external dependency requires a port first
- Effort: ~3 weeks to retrofit existing features
- Reversibility: medium (the port contracts become part of the public surface)
Decision
Option C — hexagonal architecture, enforced via ArchUnitTS fitness tests.
Primary reason: the project is committed to agentic-first development. Agentic contributors follow mechanical boundaries reliably; they drift through soft conventions invisibly. A flat folder or FSD approach leaves the I/O boundary unenforced and produces the same coupling problem that hex arch solves.
Dissenting consideration: the added file count slows initial feature velocity. Mitigated by: code generators (yarn new:hook, yarn new:mutation, yarn new:screen) produce fully-compliant scaffolding, so the overhead is tool-mediated rather than manual.
Consequences
Positive
- Presentation hooks are testable against in-memory port stubs — no Supabase instance required
generated.types.tsis mechanically confined toadapters/supabase/(ARCH-3 ArchUnitTS rule); consumers see only domain entities- The dependency direction is enforced at every push via 16 ArchUnitTS fitness rules in
packages/app/src/config/__tests__/architecture-archunit.test.ts - Adapters are swappable: a mock adapter in tests, a real Supabase adapter in production, a future Postgres-direct adapter without touching any presentation code
Negative
- Every new external dependency requires a port method + adapter implementation before any presentation hook can use it — adds 2-4 files per feature capability
- Engineers trained on flat-folder RN apps face a steeper onboarding curve
- 39 AGENTS.md hard constraints relate to this decision; maintaining them is ongoing work
Neutral
- The registry pattern (
packages/app/src/registry.ts) is the single wiring point; there is no DI framework — plain function imports suffice at this scale
Implementation notes
- ArchUnitTS rules:
packages/app/src/config/__tests__/architecture-archunit.test.ts(16 rules) - ESLint rules enforcing the hex direction:
@twomore/no-direct-supabase-in-presentation(ARCH-8),@twomore/no-supabase-auth-outside-adapter(DATA-5) - Port implementations wired in:
packages/app/src/registry.ts - Mapper convention: every adapter has a sibling
mappers/file with explicittoDomain()/toRow()conversions — never raw column names in presentation code
References
- AGENTS.md — ARCH-1 through ARCH-8 rules
- shared-client-architecture.md
- agentic-project-foundation.md — Phase P4 (ArchUnitTS)
- ADR 0001 — the enforcement substrate this decision relies on
- Martin. Clean Architecture (2017) — ports-and-adapters exposition