Repository pattern & extensible data modeling
Layering, ports/adapters, and how both your projects organize code for growth without rewrites.
Concept → your projects
The repository pattern puts an interface between domain logic and persistence: services depend on AccessRequestRepository (an interface), not on SQLC or Prisma details. Benefits: testability (your tests/mocks/repositories/ in zoth — 13 mock repos — exist because of this), swap-ability (theoretical), and query ownership — one place where SQL lives, so performance work has an address.
Zoth's layering (Go, classic ports & adapters)
controllers (HTTP: auth, identity, access, policy, audit, integration…) │ use interfaces only services (jit, approval, policy, rag, audit, emitter, platforms, drift…) │ repository interfaces (internal/repositories/interfaces.go) repositories/postgres (wrap internal/db/sqlc — SQLC-generated from queries.sql) │ PostgreSQL + pgvector
Real details that show craft:
- SQLC compiles
queries.sqlinto typed Go — the repository layer wraps generated code rather than hand-writing SQL strings. Your querier interface +emit_interface: truemeans services can be tested against generated fakes. - Interfaces at the service seam too:
emitter.Servicedepends on aBatonServiceinterface (GrantAccess/RevokeAccess) —PlatformBridgeis just one implementation; tests inject fakes. The router even has aragAdapterbridgingrag.Serviceinto the JIT service's narrower interface — dependency inversion in the wild. zlink.Connector(5 methods + capabilities) is the same pattern at the network boundary: platform SDKs behind one interface with per-platform fakes for tests.
Traverse's layering (TS)
routes → controllers → services → lib/Prisma. Route files are deliberately anemic (13-line mounts binding controller methods). The one documented exception — social.routes.ts with inline handlers — is called out in the project's own ARCHITECTURE.md as debt. The lesson to articulate: consistency of layering is itself a feature; a service-layer extraction is trivial only if controllers stay thin.
Where traverse embodies the pattern's spirit without the ceremony: no repository interfaces — Prisma is the port. That's a legitimate choice at this scale; the zoth version exists because hand-written SQL + interfaces was the point of writing Go.
Extensible data modeling — the real test: schema change
Extensibility is measured by the migration you didn't fear:
- Zoth added
users.role(005), notifications (004), the outbox (006), vector dims (007) — each shipped as an additive goose migration; none broke existing queries because reads select explicit columns. - Both projects use additive evolution: new nullable columns, new tables, new enum values (
ALTER TYPE audit_action ADD VALUE). Destructive changes (Neo4j drop) were done once, deliberately, in one migration. - Traverse's string state machines (
status,type,outcomeas TEXT, not Prisma enums) are a documented extensibility choice: adding a friendship state is a code change, not a migration. Tradeoff: DB-level enum safety is given up (a CHECK constraint could recover most of it).
Modeling for the future (the heuristics)
- Model invariants as constraints, not conventions: traverse's
FriendStreak.userId1 < userId2CHECK, the@@unique([userId, idempotencyKey]), zoth'skey_hash UNIQUE. Constraints are the only business rules that can't be forgotten. - Timestamps everywhere (
created_at,updated_aton everything) — analytics and debugging are retrofits otherwise. - Enum-ish columns as TEXT + app-level validation when states evolve fast; real DB enums when the set is frozen (zoth's
access_request_statusis a DB enum — six states, stable, worth the safety). - Soft keys for external joins: zoth resources carry
external_id(the GitHub repo ID); traverse problems key on(platform, slug). Your system's IDs and the world's IDs must never be conflated.
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Why keep repository interfaces when SQLC already generates typed code?
SQLC gives compile-time SQL correctness, but services shouldn't import generated queriers directly: the interface seam is what makes 13 mock repositories possible (tests never touch a DB for service tests), what lets the emitter's BatonService be faked in unit tests, and what confines every query to one layer — when a query is slow, the fix has one address. The cost is a thin wrapper layer; the benefit is that the 19-test JIT suite runs in milliseconds without testcontainers.
Your projects use both DB enums and TEXT state columns. How do you choose?
Stability and blast radius. Zoth's access_request_status is a DB enum because the six states are the security contract — an invalid state must be rejected by the database itself. Traverse's status/type columns are TEXT because the product iterates (friendship states grew, notification types grow) and a migration per new value would slow that down; validation lives in the service layer and tests. The rule: enums for contracts, strings for product surface.
What's the cost of the repository pattern?
Indirection — finding the real SQL means hopping service → interface → postgres wrapper → generated code. For a solo small project that's real friction, and traverse reasonably skips it. It pays off the moment tests need isolation or persistence might change — which for zoth was immediately (13 mocked repo suites). I'd also add: don't hand-write what SQLC/Prisma already generate — the pattern should wrap generated code, not replace it.