Repositories
Repositories persist and rehydrate aggregates — collection-like interface hiding persistence technology from domain layer.
Introduction
Repositories persist and rehydrate aggregates — collection-like interface hiding persistence technology from domain layer. Uber trip aggregates load and save through TripRepository without domain code knowing about Cassandra, indexes, or cache layers.
Real production story
Uber's early trip service leaked SQL and Cassandra CQL into application services — domain rules interleaved with query strings, making tests require database containers and slowing aggregate refactors. A trip pricing change required editing twelve query sites; one missed site caused wrong fare on pooled rides in Brazil. Repository introduction defined TripRepository with findById, save, and optimistic version check; infrastructure module implemented mapping; domain tests used in-memory fake. Pricing change touched one aggregate and one repository mapper — Brazil fare bug class eliminated; domain test runtime dropped from 90s to 200ms.
Business problem
Uber trip lifecycle spans request, match, ride, payment — complex aggregate graphs persisted in polyglot stores. Persistence leakage couples domain to ops changes and blocks fast testing.
- Change cost: Schema migration requires hunting SQL across services — error-prone.
- Test velocity: Domain tests without repos require heavy integration environments.
- Team boundaries: Infra team owns storage tuning; domain team owns rules — repository is the seam.
Architecture overview
Repository contract: get by ID returns aggregate or null; save persists full aggregate consistency boundary; delete rare — prefer soft lifecycle on aggregate. Query methods minimal — complex reads go to read models/CQRS.
- Per aggregate root: TripRepository not TableRepository.
- Mapping: Repository maps TripJpaEntity ↔ Trip domain — both directions.
- Queries: Avoid rich query APIs on repo — Specification or read side handles reports.
- Transaction: Repository participates in unit of work — one aggregate per transaction.
Architecture motivation
Repository pattern isolates persistence as infrastructure concern — domain depends on interface; adapter implements with ORM, Cassandra, or event store.
- Force: Polyglot persistence (hot trip state in Cassandra, audit in S3).
- Constraint: One repository per aggregate root — not generic DAO for all tables.
- Outcome: Domain unit tests with in-memory repository; storage swaps without rule changes.
Internal architecture
Uber trip persistence layering — repository as port:
- Hexagonal architecture: repository is outbound port.
- Integration tests hit CassandraTripRepository; domain tests use FakeTripRepository.
Application Service↓ callsTripRepository (interface — domain module)↓ implementsCassandraTripRepository (infra module)├─ TripPersistenceMapper├─ Cassandra session + prepared statements└─ OutboxWriter (same transaction boundary)Trip aggregate ← never imports Cassandra driver
Data flow
Load path: service calls repo.findById(TripId) → mapper rehydrates aggregate from rows → domain methods execute → repo.save(aggregate, expectedVersion) → mapper to persistence model → write with LWT version check → outbox events.
- Find: Primary key lookup only on write repo — O(1) by TripId.
- Save: Optimistic locking column; conflict throws AggregateVersionConflict.
- Read reports: TripSummaryQueryService hits read replica — not repository.
System design diagram
Two diagrams show the Repositories topology and the primary request/event path used in production at scale.
Production code example
Cassandra trip repository adapter — Java infrastructure implementation:
- LWT IF version implements optimistic concurrency — domain stays free of Cassandra syntax via mapper tests.
- Outbox publish after successful LWT — events only on committed state.
public class CassandraTripRepository implements TripRepository {private final CqlSession session;private final TripPersistenceMapper mapper;@Overridepublic Optional<Trip> findById(TripId id) {Row row = session.execute("SELECT * FROM trips WHERE trip_id = ?",uuid(id.value())).one();return row == null ? Optional.empty() : Optional.of(mapper.toDomain(row));}@Overridepublic void save(Trip trip, long expectedVersion) {TripRow row = mapper.toPersistence(trip);boolean applied = session.execute("""UPDATE trips SET status = ?, fare = ?, version = ?WHERE trip_id = ? IF version = ?""",row.status(), row.fareMicros(), row.version(),uuid(trip.id().value()), expectedVersion).wasApplied();if (!applied) throw new AggregateVersionConflict(trip.id());outbox.publish(trip.pullEvents());}}
Enterprise case study
Uber TripRepository extraction: Persistence leakage caused fare bugs and slow domain tests.
- Before: CQL in twelve services; Brazil pooled fare bug; domain tests 90s with Testcontainers.
- Decision: TripRepository interface, Cassandra adapter, fake for unit tests, mapping tests.
- After: Fare logic changes single location; domain tests 200ms; zero mapper omission bugs in 12 months.
Trade-offs
- Repository vs active record: Active record faster early; repository pays off at domain complexity threshold.
- Generic repo anti-pattern: Repository<T> for all entities — loses aggregate mapping control.
- ORM in repository: Acceptable in adapter — never expose EntityManager to domain.
- Event sourcing: Repository loads by replaying events — different adapter, same interface to domain.
Security considerations
Repository enforces access at load: findById checks tenant/driver authorization before returning aggregate — or return null to prevent enumeration.
- Encryption: Repository adapter encrypts PII columns at rest — domain sees decrypted VO inside aggregate only.
- Audit: Save path logs actor and aggregate version — tamper-evident trail.
- SQL injection: Prepared statements only in adapter — domain never builds queries.
Scalability analysis
Repository implementation scales storage — domain interface stable while sharding Cassandra, adding read replicas, or migrating to CRDB.
- Shard awareness: Repository resolves TripId to partition — hidden from domain.
- Connection pools: Infra tunes pool; domain unaware.
- Batch save anti-pattern: Do not batch unrelated aggregates — breaks consistency boundary.
Failure scenarios
Repository failures: mapper drops fields; save without version check; N+1 load inside mapper; repository called for report queries overloading OLTP.
- Partial rehydrate: Mapper misses new field — add round-trip mapping tests.
- Lost update: No optimistic lock — two drivers accept same trip — version column mandatory.
- Query creep: findByDriverAndDate on write repo — move to read model before index explosion.
Staff engineer insights
- Repository per aggregate root — if your repository has twenty unrelated find methods, it's a leaked DAO.
- The fake in-memory repository is the fastest domain test investment — write it day one, not after first outage.
- Read models are not repositories — querying for dashboards through write repo destroys OLTP and confuses boundaries.
Interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionRepository vs DAO vs active record — when each?+
Answer
Follow-up
2AdvancedQuestionWhere do complex list/search queries go?+
Answer
Follow-up
3AdvancedQuestionDesign repository for event-sourced aggregate.+
Answer
Follow-up
Architecture review questions
- Is there one repository interface per aggregate root?
- Does domain layer depend only on repository interface — not ORM/driver?
- Are mapper round-trip tests present for persistence model?
- Is optimistic concurrency enforced on save?
- Are report queries excluded from write repository?
- Is in-memory fake repository available for fast domain unit tests?
Summary
Repositories at Uber isolate trip aggregate persistence behind TripRepository — Cassandra adapter, optimistic versioning, and in-memory fakes — so domain rules evolve without CQL leakage or fare-calculation bugs from scattered queries.