Skip to main content

Implementing Entity Fetchers

EntityFetcher[F, A, Id] loads entities from your data store and converts them to Cedar entities.

Type Parameters

  • F[_] - Effect type (Future, IO, etc.)
  • A - Entity type (generated Cedar entity class)
  • Id - Entity ID type (typed ID like DocumentId, UserId, etc.)

Basic Implementation

import cedar4s.entities.EntityFetcher
import scala.concurrent.Future
import scala.concurrent.ExecutionContext

// Import generated Cedar entity types and IDs
import com.example.myapp.cedar.EntityIds.{DocumentId, FolderId, UserId}

class DocumentFetcher(db: Database)(using ec: ExecutionContext)
extends EntityFetcher[Future, MyApp.Entity.Document, DocumentId] {

def fetch(id: DocumentId): Future[Option[MyApp.Entity.Document]] =
db.findDocument(id.value).map(_.map { doc =>
// Convert domain model to generated Cedar entity
MyApp.Entity.Document(
id = DocumentId(doc.id),
folderId = FolderId(doc.folderId),
name = doc.name,
owner = UserId(doc.ownerId),
editors = doc.editors.map(UserId(_)).toSet,
locked = doc.locked
)
})
}

The fetch method:

  • Takes a typed entity ID
  • Returns F[Option[A]] - the entity if found, None if not
  • Converts your domain model to the generated Cedar entity type with typed IDs

Factory Methods

For simple cases, use factory methods instead of extending the trait:

// Typed IDs (recommended)
val fetcher = EntityFetcher[Future, MyApp.Entity.User, UserId] { id =>
db.findUser(id.value).map(_.map(toCedar))
}

// With batch support
val fetcher = EntityFetcher.withBatch[Future, MyApp.Entity.User, UserId](
f = id => db.findUser(id.value).map(_.map(toCedar)),
batch = ids => db.findUsers(ids.map(_.value)).map(_.map(u => UserId(u.id) -> toCedar(u)).toMap)
)

Implementing fetchBatch

Override fetchBatch for efficient multi-entity loading:

class DocumentFetcher(db: Database)(using ec: ExecutionContext)
extends EntityFetcher[Future, MyApp.Entity.Document, DocumentId] {

def fetch(id: DocumentId): Future[Option[MyApp.Entity.Document]] =
db.findDocument(id.value).map(_.map(toCedar))

// Single SQL query for all IDs
override def fetchBatch(ids: Set[DocumentId])(implicit F: Applicative[Future]): Future[Map[DocumentId, MyApp.Entity.Document]] =
db.run(documents.filter(_.id.inSet(ids.map(_.value))).result).map { docs =>
docs.map(d => DocumentId(d.id) -> toCedar(d)).toMap
}

private def toCedar(doc: DomainDocument): MyApp.Entity.Document =
MyApp.Entity.Document(
id = DocumentId(doc.id),
folderId = FolderId(doc.folderId),
name = doc.name,
owner = UserId(doc.ownerId),
editors = doc.editors.map(UserId(_)).toSet,
locked = doc.locked
)
}

Why fetchBatch Matters

Without fetchBatch, loading 100 entities requires 100 database queries:

SELECT * FROM documents WHERE id = 'doc-1'  -- 5ms
SELECT * FROM documents WHERE id = 'doc-2' -- 5ms
... (98 more queries)
Total: ~500ms

With fetchBatch, one query:

SELECT * FROM documents WHERE id IN ('doc-1', 'doc-2', ...)  -- 5ms
Total: ~5ms (100x faster)

Converting Domain Models

Keep the conversion logic clean and testable:

class DocumentFetcher(db: Database)(using ec: ExecutionContext)
extends EntityFetcher[Future, MyApp.Entity.Document, DocumentId] {

def fetch(id: DocumentId): Future[Option[MyApp.Entity.Document]] =
db.findDocument(id.value).map(_.map(DocumentConverter.toCedar))

override def fetchBatch(ids: Set[DocumentId])(using Applicative[Future]) =
db.findDocuments(ids.map(_.value)).map(_.map(d => DocumentId(d.id) -> DocumentConverter.toCedar(d)).toMap)
}

object DocumentConverter {
def toCedar(doc: DomainDocument): MyApp.Entity.Document =
MyApp.Entity.Document(
id = DocumentId(doc.id),
folderId = FolderId(doc.folderId),
name = doc.name,
owner = UserId(doc.ownerId),
editors = doc.editors.map(UserId(_)).toSet,
locked = doc.lockedAt.isDefined
)
}

Handling Missing Entities

When an entity doesn't exist, return None:

def fetch(id: DocumentId): Future[Option[Entities.Document]] =
db.findDocument(id.value).map(_.map(toCedar)) // Returns None if not found

For fetchBatch, simply omit missing entities from the result map:

override def fetchBatch(ids: Set[DocumentId])(using Applicative[Future]) =
db.findDocuments(ids.map(_.value)).map { docs =>
// Only includes documents that exist
docs.map(d => DocumentId(d.id) -> toCedar(d)).toMap
}

Missing entities in authorization checks result in denial - Cedar requires all referenced entities to exist.

Typed IDs

Cedar4s generates typed IDs (newtypes) for type safety. Each entity gets a distinct ID type that prevents accidentally mixing up different entity types:

// Generated in EntityIds.scala using Newtype abstraction
import cedar4s.{Bijection, Newtype}

object EntityIds {
/** ID type for Document entities */
object DocumentId extends Newtype[String]
type DocumentId = DocumentId.Type

/** ID type for Folder entities */
object FolderId extends Newtype[String]
type FolderId = FolderId.Type
}

// Usage:
val docId: DocumentId = DocumentId("doc-123")
val str: String = docId.value

// Newtype provides opaque types - zero runtime cost
// with compile-time type safety

The Newtype base class provides:

  • apply(value: String): Type - wrap a String
  • extension (id: Type) def value: String - unwrap to String
  • unapply(id: Type): Some[String] - pattern matching
  • bijection: Bijection[String, Type] - bidirectional conversion

Use typed IDs throughout your fetchers:

class DocumentFetcher(db: Database)(using ec: ExecutionContext)
extends EntityFetcher[Future, MyApp.Entity.Document, DocumentId] {

def fetch(id: DocumentId): Future[Option[MyApp.Entity.Document]] =
db.findDocument(id.value).map(_.map(toCedar))

override def fetchBatch(ids: Set[DocumentId])(using Applicative[Future]) =
db.findDocuments(ids.map(_.value)).map { docs =>
docs.map(d => DocumentId(d.id) -> toCedar(d)).toMap
}
}

This ensures you can't accidentally pass a UserId where a DocumentId is expected.