Day 13 of 100·System Design Day·559 words

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.sql into typed Go — the repository layer wraps generated code rather than hand-writing SQL strings. Your querier interface + emit_interface: true means services can be tested against generated fakes.
  • Interfaces at the service seam too: emitter.Service depends on a BatonService interface (GrantAccess/RevokeAccess) — PlatformBridge is just one implementation; tests inject fakes. The router even has a ragAdapter bridging rag.Service into 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, outcome as 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)

  1. Model invariants as constraints, not conventions: traverse's FriendStreak.userId1 < userId2 CHECK, the @@unique([userId, idempotencyKey]), zoth's key_hash UNIQUE. Constraints are the only business rules that can't be forgotten.
  2. Timestamps everywhere (created_at, updated_at on everything) — analytics and debugging are retrofits otherwise.
  3. Enum-ish columns as TEXT + app-level validation when states evolve fast; real DB enums when the set is frozen (zoth's access_request_status is a DB enum — six states, stable, worth the safety).
  4. 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.

01

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.

02

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.

03

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.