Skip to content

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:

  1. Untestable hooks — presentation hooks imported supabase directly, making unit tests impossible without spinning up a real Supabase instance.
  2. Supabase type bleedgenerated.types.ts (Supabase's auto-generated DB schema) was imported from presentation hooks and feature packages, coupling every consumer to the DB column names.
  3. 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 from shared/ — 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.ts is mechanically confined to adapters/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 explicit toDomain() / toRow() conversions — never raw column names in presentation code

References

Markdown remains the source of truth. Run yarn docs:check before handoff.