Repository Pattern
The repository pattern abstracts data access, providing a clean interface between your domain logic and database operations.
Why Repositories?
Repositories provide:
- Abstraction - Domain logic doesn't know about SQL
- Testability - Easy to mock for unit tests
- Organization - Clear location for data access code
- Consistency - Standard patterns across your codebase
Basic Repository
typescript
import { Effect, Option, Data } from "effect"
import { SqlClient, SqlSchema } from "@effect/sql"
import { Schema } from "effect"
// Domain model
class User extends Schema.Class<User>("User")({
id: Schema.Number,
email: Schema.String,
name: Schema.String,
createdAt: Schema.Date
}) {}
// Repository service
export class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
effect: Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return {
findById: (id: number) =>
Effect.gen(function* () {
const users = yield* sql<User>`SELECT * FROM users WHERE id = ${id}`
return Option.fromNullable(users[0])
}),
findByEmail: (email: string) =>
Effect.gen(function* () {
const users = yield* sql<User>`SELECT * FROM users WHERE email = ${email}`
return Option.fromNullable(users[0])
}),
create: (data: { email: string; name: string }) =>
Effect.gen(function* () {
const [user] = yield* sql<User>`
INSERT INTO users ${sql.insert(data)} RETURNING *
`
return user
}),
update: (id: number, data: { name?: string; email?: string }) =>
Effect.gen(function* () {
const [user] = yield* sql<User>`
UPDATE users SET ${sql.update({ id, ...data }, ["id"])}
WHERE id = ${id}
RETURNING *
`
return user
}),
delete: (id: number) =>
Effect.gen(function* () {
yield* sql`DELETE FROM users WHERE id = ${id}`
})
}
})
}) {}Using the Repository
typescript
const program = Effect.gen(function* () {
const userRepo = yield* UserRepository
// Create
const user = yield* userRepo.create({
email: "alice@example.com",
name: "Alice"
})
// Read
const found = yield* userRepo.findById(user.id)
// Update
const updated = yield* userRepo.update(user.id, { name: "Alice Smith" })
// Delete
yield* userRepo.delete(user.id)
})
// Provide the repository
program.pipe(
Effect.provide(UserRepository.Default),
Effect.provide(DatabaseLive)
)Repository with Schema Validation
Use SqlSchema for validated queries:
typescript
export class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
effect: Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
const findById = SqlSchema.findOne({
Request: Schema.Number,
Result: User,
execute: (id) => sql`SELECT * FROM users WHERE id = ${id}`
})
const findAll = SqlSchema.findAll({
Request: Schema.Void,
Result: User,
execute: () => sql`SELECT * FROM users ORDER BY created_at DESC`
})
const create = SqlSchema.single({
Request: Schema.Struct({ email: Schema.String, name: Schema.String }),
Result: User,
execute: (data) => sql`INSERT INTO users ${sql.insert(data)} RETURNING *`
})
return {
findById,
findAll: () => findAll(undefined),
create
}
})
}) {}Repository with Models
Use Model.makeRepository for common CRUD:
typescript
import { Model } from "@effect/sql"
class User extends Model.Class<User>("User")({
id: Model.Generated(Schema.Number.pipe(Schema.brand("UserId"))),
email: Schema.String,
name: Schema.String,
createdAt: Model.DateTimeInsertFromDate
}) {}
export class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
effect: Effect.gen(function* () {
const repo = yield* Model.makeRepository(User, {
tableName: "users",
spanPrefix: "UserRepo",
idColumn: "id"
})
// Add custom methods
const findByEmail = (email: string) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
const users = yield* sql<User>`SELECT * FROM users WHERE email = ${email}`
return Option.fromNullable(users[0])
})
return {
...repo,
findByEmail
}
})
}) {}Domain Errors
Define domain-specific errors:
typescript
import { Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: number
}> {}
class EmailAlreadyExists extends Data.TaggedError("EmailAlreadyExists")<{
email: string
}> {}
export class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
effect: Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return {
findByIdOrFail: (id: number) =>
Effect.gen(function* () {
const users = yield* sql<User>`SELECT * FROM users WHERE id = ${id}`
if (users.length === 0) {
return yield* Effect.fail(new UserNotFound({ userId: id }))
}
return users[0]
}),
create: (data: { email: string; name: string }) =>
Effect.gen(function* () {
// Check for existing email
const existing = yield* sql`SELECT 1 FROM users WHERE email = ${data.email}`
if (existing.length > 0) {
return yield* Effect.fail(new EmailAlreadyExists({ email: data.email }))
}
const [user] = yield* sql<User>`
INSERT INTO users ${sql.insert(data)} RETURNING *
`
return user
}).pipe(
Effect.catchTag("SqlError", (error) => {
// Handle unique constraint at DB level
const cause = error.cause as any
if (cause?.code === "23505") {
return Effect.fail(new EmailAlreadyExists({ email: data.email }))
}
return Effect.fail(error)
})
)
}
})
}) {}Transaction Support
Wrap multiple repository operations in transactions:
typescript
class OrderRepository extends Effect.Service<OrderRepository>()("OrderRepository", {
effect: Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return {
createWithItems: (order: NewOrder, items: NewOrderItem[]) =>
sql.withTransaction(
Effect.gen(function* () {
const [newOrder] = yield* sql`
INSERT INTO orders ${sql.insert(order)} RETURNING *
`
yield* sql`
INSERT INTO order_items ${sql.insert(
items.map(item => ({ ...item, orderId: newOrder.id }))
)}
`
return newOrder
})
)
}
})
}) {}Testing Repositories
typescript
// Mock repository for tests
const mockUserRepo: typeof UserRepository.Service = {
findById: () => Effect.succeed(Option.some(mockUser)),
create: () => Effect.succeed(mockUser),
// ...
}
const MockUserRepository = Layer.succeed(UserRepository, mockUserRepo)
// Test with real database
const TestLayer = UserRepository.Default.pipe(
Layer.provideMerge(SqliteClient.layer({ filename: ":memory:" }))
)
it.effect("creates users", () =>
Effect.gen(function* () {
yield* setupDatabase
const repo = yield* UserRepository
const user = yield* repo.create({ email: "test@example.com", name: "Test" })
expect(user.email).toBe("test@example.com")
}).pipe(Effect.provide(TestLayer))
)Composition
Compose repositories for complex operations:
typescript
class UserService extends Effect.Service<UserService>()("UserService", {
effect: Effect.gen(function* () {
const userRepo = yield* UserRepository
const profileRepo = yield* ProfileRepository
const sql = yield* SqlClient.SqlClient
return {
createUserWithProfile: (userData: NewUser, profileData: NewProfile) =>
sql.withTransaction(
Effect.gen(function* () {
const user = yield* userRepo.create(userData)
const profile = yield* profileRepo.create({
...profileData,
userId: user.id
})
return { user, profile }
})
)
}
})
}) {}Best Practices
- Keep repositories focused - One aggregate root per repository
- Return domain types - Not raw database rows
- Use domain errors - Not generic SQL errors
- Encapsulate queries - Don't expose SQL details
- Support transactions - Allow composition
- Make it testable - Use Effect Services for DI
Next Steps
- Models - Type-safe domain models
- Data Loaders - Batching with repositories
- Testing - Testing strategies