cats-eo

Avro integration

The cats-eo-avro module adds two cursor-backed optics for editing Apache Avro records without paying the cost of a full decode / re-encode round-trip.

libraryDependencies += "dev.constructive" %% "cats-eo-avro" % "0.13"

Why this exists

Avro lives in streaming pipelines: Kafka topics, S3 sinks, Schema Registry-backed services. Payloads arrive on the wire as Array[Byte] (binary) or String (Avro JSON), each one carrying its own schema. The classical Scala edit pattern is:

codec.decode(bytes).map { p =>
  p.copy(name = p.name.toUpperCase)
}.map(codec.encode)

That decodes every field of Person, allocates a fresh instance, and re-encodes every field — even when only one leaf is changing. For wide records the work is mostly wasted, and the whole pipeline budget is spent on serialisation rather than on whatever business logic the consumer actually wants to run.

AvroPrism is a byte-carried optic (Optic[Array[Byte], Array[Byte], A, A, Affine]): it locates the focused field's byte span in the binary payload, decodes only that slice on read, and splices the re-encoded focus back in place on write — no record materialised. When you hold a parsed IndexedRecord instead, .record flips the same drilled optic to a record-carried face (AvroRecordPrism / AvroRecordTraversal) that walks the record, modifying only the focused leaf and rebuilding the parents on the way up. The OrderAvroBench suite documents the speedup against the kindlings-avro-derivation codec round-trip — see the benchmarks page for the full table.

The codec backend is kindlings-avro-derivation 0.1.2, which pins apache-avro 1.12.1. cats-eo-avro wraps the kindlings AvroEncoder[A] / AvroDecoder[A] / AvroSchemaFor[A] triplet in a single AvroCodec[A] typeclass so user code summons one thing per type.

AvroPrism

import dev.constructive.eo.avro.{AvroCodec, codecPrism}
import hearth.kindlings.avroderivation.{AvroDecoder, AvroEncoder, AvroSchemaFor}

case class Address(street: String, zip: Int)
object Address:
  given AvroEncoder[Address] = AvroEncoder.derived
  given AvroDecoder[Address] = AvroDecoder.derived
  given AvroSchemaFor[Address] = AvroSchemaFor.derived

case class Person(name: String, age: Int, address: Address)
object Person:
  given AvroEncoder[Person] = AvroEncoder.derived
  given AvroDecoder[Person] = AvroDecoder.derived
  given AvroSchemaFor[Person] = AvroSchemaFor.derived

Construct a Prism to the root type, then drill into fields. The .address.street sugar is macro-powered — it compiles to .field(_.address).field(_.street). The input at hand below is a parsed GenericRecord, so the examples flip to the record-carried face with .record (bytes input needs no flip — the prism itself is the byte optic):

import org.apache.avro.generic.GenericRecord

val alice   = Person("Alice", 30, Address("Main St", 12345))
// alice: Person = Person(
//   name = "Alice",
//   age = 30,
//   address = Address(street = "Main St", zip = 12345)
// )
val record  = summon[AvroCodec[Person]].encode(alice).asInstanceOf[GenericRecord]
// record: GenericRecord = {"name": "Alice", "age": 30, "address": {"street": "Main St", "zip": 12345}}
val streetP = codecPrism[Person].address.street
// streetP: AvroPrism[String] = dev.constructive.eo.avro.AvroPrism@5ef3ff64

streetP.record.modifyUnsafe(_.toUpperCase)(record)
  .asInstanceOf[GenericRecord].get("address")
// res0: Object = {"street": "MAIN ST", "zip": 12345}

On the record face the default modify returns Ior[Chain[AvroFailure], IndexedRecord] — failures are surfaced rather than silently swallowed, mirroring JsonPrism's default surface. The *Unsafe variants ship the silent-pass-through hot path used by Kafka consumers that have measured and don't want the diagnostic allocation.

Other operations (all the silent escape hatches):

streetP.record.getOptionUnsafe(record)
// res1: Option[String] = Some("Main St")
streetP.record.placeUnsafe("Broadway")(record)
  .asInstanceOf[GenericRecord].get("address")
// res2: Object = {"street": "Broadway", "zip": 12345}
streetP.record.transformUnsafe(any => any)(record).getSchema.getName
// res3: String = "Person"

Forgiving semantics on the *Unsafe surface — missing paths leave the record unchanged:

import org.apache.avro.generic.GenericData

// A stump record carrying only `name` and `age` — the `address`
// slot is null at the schema-allocated position.
val stumpSchema = summon[AvroCodec[Person]].schema
// stumpSchema: Schema = {"type":"record","name":"Person","namespace":"MdocApp.this","fields":[{"name":"name","type":"string"},{"name":"age","type":"int"},{"name":"address","type":{"type":"record","name":"Address","fields":[{"name":"street","type":"string"},{"name":"zip","type":"int"}]}}]}
val stump = new GenericData.Record(stumpSchema)
// stump: Record = {"name": "Alice", "age": 30, "address": null}
stump.put(stumpSchema.getField("name").pos, "Alice")
stump.put(stumpSchema.getField("age").pos, 30)
// `address` left null — the walker hits `NotARecord` at that step.

streetP.record.modifyUnsafe(_.toUpperCase)(stump).asInstanceOf[GenericRecord].get("address")
// res6: Object = null

Field navigation is by SCHEMA name — .fieldNamed is the escape hatch

.field(_.x) (and the .fields(...) / dynamic-sugar siblings) resolve the case-class field x to whatever schema field the codec actually emitted for it — by declaration position: the i-th case field maps to the i-th schema field, read off the cached schema at construction time, at zero per-operation cost. That makes navigation robust under any field-name transform — a kindlings snake / kebab / custom transformFieldNames config, or a vulcan per-field override map — so a renamed schema field is never a miss cause on a derived codec. The one shape position resolution cannot handle is a hand-written codec whose schema field order diverges from case-class declaration order: there, drill with .fieldNamed[B]("schema_name"), which navigates by the explicit schema name and bypasses position resolution entirely. Map keys are data, not schema-named fields — they keep their literal key.

Array indexing

.at(i) drills into the i-th element of an Avro array<T>:

case class Order(name: String)
object Order:
  given AvroEncoder[Order] = AvroEncoder.derived
  given AvroDecoder[Order] = AvroDecoder.derived
  given AvroSchemaFor[Order] = AvroSchemaFor.derived

case class Basket(owner: String, items: List[Order])
object Basket:
  given AvroEncoder[Basket] = AvroEncoder.derived
  given AvroDecoder[Basket] = AvroDecoder.derived
  given AvroSchemaFor[Basket] = AvroSchemaFor.derived
val basket = Basket("Alice", List(Order("X"), Order("Y"), Order("Z")))
// basket: Basket = Basket(
//   owner = "Alice",
//   items = List(Order("X"), Order("Y"), Order("Z"))
// )
val basketRec =
  summon[AvroCodec[Basket]].encode(basket).asInstanceOf[GenericRecord]
// basketRec: GenericRecord = {"owner": "Alice", "items": [{"name": "X"}, {"name": "Y"}, {"name": "Z"}]}
val secondName = codecPrism[Basket].items.at(1).name
// secondName: AvroPrism[String] = dev.constructive.eo.avro.AvroPrism@311f62ee

secondName.record.getOptionUnsafe(basketRec)
// res7: Option[String] = Some("Y")
secondName.record.modifyUnsafe(_.toUpperCase)(basketRec)
  .asInstanceOf[GenericRecord].get("items").toString
// res8: String = "[{\"name\": \"X\"}, {\"name\": \"Y\"}, {\"name\": \"Z\"}]"

Out-of-range / negative / non-array positions surface as Ior.Both(chain, inputRecord) on the default surface — the walker accumulates IndexOutOfRange / NotAnArray / PathMissing and threads the original record back through.

AvroTraversal (.each)

.each splits the path at the current array focus and returns an AvroTraversal that walks every element. Further .field / .at / .union[Branch] / .fields(...) / selectable-sugar calls on the traversal extend the per-element suffix — the same drill surface as the prism, applied per element (e.g. entries.each.union[Cash] narrows every element to its Cash branch, folding the ones that are Cash and leaving the rest). Like the prism, the traversal is byte-carried by default — .foldMap / .modify operate on Array[Byte] directly, splicing every element's focus in one pass — and .record flips to the record-carried Ior surface:

val everyName = codecPrism[Basket].items.each.name
// everyName: AvroTraversal[String] = dev.constructive.eo.avro.AvroTraversal@58855723

everyName.record.modifyUnsafe(_.toUpperCase)(basketRec)
  .asInstanceOf[GenericRecord].get("items").toString
// res9: String = "[{\"name\": \"X\"}, {\"name\": \"Y\"}, {\"name\": \"Z\"}]"
everyName.record.getAllUnsafe(basketRec)
// res10: Vector[String] = Vector("X", "Y", "Z")

Empty arrays and missing prefixes leave the record unchanged on the silent surface; on the record face's default Ior surface, prefix-walk failures land in Ior.Left (nothing to iterate) and per-element skips accumulate one AvroFailure per refused element into Ior.Both(chain, partialRecord). Payloads whose arrays use the byte-sized (negative-count) block framing are readable as-is and RE-FRAMED on write: the array region is rebuilt as one canonical positive-count block with the splices applied.

Multi-field focus — .fields(_.a, _.b)

.fields(selector1, selector2, ...) focuses a bundle of named case-class fields as a Scala 3 NamedTuple. Selectors arrive in selector-order; the NamedTuple type reflects that. Arity must be ≥ 2 — use .field(_.x) for a single-field focus.

type NameAge = NamedTuple.NamedTuple[("name", "age"), (String, Int)]
given AvroEncoder[NameAge] = AvroEncoder.derived
given AvroDecoder[NameAge] = AvroDecoder.derived
given AvroSchemaFor[NameAge] = AvroSchemaFor.derived
val nameAge = codecPrism[Person].fields(_.name, _.age)
// nameAge: AvroPrism[NamedTuple[*:["name", *:["age", EmptyTuple]], *:[String, *:[Int, EmptyTuple]]]] = dev.constructive.eo.avro.AvroPrism@56c3c246

nameAge
  .record
  .modifyUnsafe(nt => (name = nt.name.toUpperCase, age = nt.age + 1))(record)
  .asInstanceOf[GenericRecord].get("name")
// res11: Object = "ALICE"

Full-cover selection — spanning every field of Person — still returns an AvroPrism (fields-focused), not an Iso. Avro decode can always fail (the input may not even be record-shaped, the codec may refuse a field, the union branch may not match) so totality isn't witnessable, and an Iso would misleadingly advertise a guarantee we cannot provide.

Per-element multi-field focus via .each.fields focuses a NamedTuple on every element of an array — same shape as JsonTraversal's .each.fields, just over the Avro carrier.

Union branches — .union[Branch]

Avro's schema-driven union<...> types — including the standard Option[A] encoding as union<null, A> and sealed-trait sums — demand a per-branch resolution step. cats-eo-avro spells this as .union[Branch]:

case class Transaction(id: String, amount: Option[Long])
object Transaction:
  given AvroEncoder[Transaction] = AvroEncoder.derived
  given AvroDecoder[Transaction] = AvroDecoder.derived
  given AvroSchemaFor[Transaction] = AvroSchemaFor.derived
val txRec = summon[AvroCodec[Transaction]]
  .encode(Transaction("t-1", Some(42L))).asInstanceOf[GenericRecord]
// txRec: GenericRecord = {"id": "t-1", "amount": 42}

val amountP = codecPrism[Transaction].field(_.amount).union[Long]
// amountP: AvroPrism[Long] = dev.constructive.eo.avro.AvroPrism@5756ce82

amountP.record.modifyUnsafe(_ + 1L)(txRec)
  .asInstanceOf[GenericRecord].get("amount")
// res12: Object = 43L

.union[Branch] works against Option[A] (union<null, A>), sealed-trait sums (union<RecordA, RecordB, ...>), Scala 3 enums, and Scala 3 untagged unions A | B | C (Branch must be one of the members). Kindlings derives the same union<...> schema for an untagged-union field as for a sealed trait, so the branch resolves at runtime off AvroCodec[Branch].schema.getFullName the same way. When the runtime value sits on a different branch than the requested one, the walker surfaces AvroFailure.UnionResolutionFailed carrying the schema-declared alternatives so the caller can route on the mismatch.

Reading diagnostics from the default Ior surface

The default modify / transform / place / transfer / get methods on the record-carried faces — .record on a drilled AvroPrism or AvroTraversal (AvroRecordPrism / AvroRecordTraversal) — all return Ior[Chain[AvroFailure], IndexedRecord] (or , A] / , Vector[A]] for the reads). Three observable shapes:

Failure flow

The diagram below traces the path an IndexedRecord | Array[Byte] | String input takes through a default-surface read/modify, showing which AvroFailure case lands in the chain at each refusal point and which Ior shape the caller observes.

flowchart TD
  input["IndexedRecord | Array[Byte] | String"] --> parsed{parses?}
  parsed -- "no (Array[Byte])" --> binFail["BinaryParseFailed"]
  parsed -- "no (String)" --> jsonFail["JsonParseFailed"]
  binFail --> iorLeft(["Ior.Left(chain)"])
  jsonFail --> iorLeft
  parsed -- yes --> walk{prefix walk}
  walk -- "field on non-record" --> notRec["NotARecord"]
  walk -- "field absent" --> pathMissing["PathMissing"]
  walk -- "at on non-array" --> notArr["NotAnArray"]
  walk -- "index out of range" --> idxOOR["IndexOutOfRange"]
  walk -- "branch mismatch" --> unionFail["UnionResolutionFailed"]
  walk -- "bad enum symbol" --> enumFail["BadEnumSymbol"]
  walk -- "decoder refused" --> decodeFail["DecodeFailed"]
  walk -- "clean path" --> travStep{traversal?}
  notRec --> opShape1{operation}
  pathMissing --> opShape1
  notArr --> opShape1
  idxOOR --> opShape1
  unionFail --> opShape1
  enumFail --> opShape1
  decodeFail --> opShape1
  opShape1 -- "get / prefix-miss traversal" --> iorLeft
  opShape1 -- "modify / place / transform" --> iorBoth(["Ior.Both(chain, inputRecord)"])
  travStep -- "no (single focus)" --> iorRight(["Ior.Right(record)"])
  travStep -- "yes" --> perElt{per-element walk}
  perElt -- "every element OK" --> iorRight
  perElt -- "some elements skip" --> iorBothPartial(["Ior.Both(chain, partialRecord)"])
  perElt -- "empty array" --> iorRight

Failure model

Eleven AvroFailure cases cover every refusal point on a walk. Each carries the PathStep at which the walker refused, plus case-specific context. The first five mirror JsonFailure case-for-case; the schema-driven and wire-format cases are Avro-only.

import dev.constructive.eo.avro.{AvroFailure, PathStep}

PathMissing — named field absent from its parent record. Mirrors JsonFailure.PathMissing:

val pm: AvroFailure = AvroFailure.PathMissing(PathStep.Field("street"))
// pm: AvroFailure = PathMissing(Field("street"))
pm.message
// res13: String = "path missing at Field(street)"

NotARecord — parent wasn't a record at the walk position (so the walker couldn't look up a field by name). Avro's schema-driven analogue of JsonFailure.NotAnObject:

val nr: AvroFailure = AvroFailure.NotARecord(PathStep.Field("address"))
// nr: AvroFailure = NotARecord(Field("address"))
nr.message
// res14: String = "expected Avro record at Field(address)"

NotAnArray — parent wasn't an array at the walk position (so the walker couldn't index). Mirrors JsonFailure.NotAnArray:

val na: AvroFailure = AvroFailure.NotAnArray(PathStep.Index(2))
// na: AvroFailure = NotAnArray(Index(2))
na.message
// res15: String = "expected Avro array at Index(2)"

IndexOutOfRange — index outside [0, size). The actual array length rides along on the failure case so callers can route on it:

val oor: AvroFailure = AvroFailure.IndexOutOfRange(PathStep.Index(7), 3)
// oor: AvroFailure = IndexOutOfRange(step = Index(7), size = 3)
oor.message
// res16: String = "index out of range at Index(7) (size=3)"

DecodeFailed — the kindlings codec refused at the focused leaf. The wrapped Throwable is whatever the underlying decoder threw — typically an AvroRuntimeException, AvroTypeException, or ClassCastException when the runtime payload doesn't line up with the schema:

val cause = new RuntimeException("missing record field 'name'")
// cause: RuntimeException = java.lang.RuntimeException: missing record field 'name'
val df: AvroFailure = AvroFailure.DecodeFailed(PathStep.Field("name"), cause)
// df: AvroFailure = DecodeFailed(
//   step = Field("name"),
//   cause = java.lang.RuntimeException: missing record field 'name'
// )
df.message
// res17: String = "decode failed at Field(name): missing record field 'name'"

BinaryParseFailed — input Array[Byte] didn't parse as Avro binary under the supplied reader schema. Surfaced only by the dual-input overloads; when the caller passes a parsed IndexedRecord directly this case cannot fire:

val binCause = new RuntimeException("EOF before record complete")
// binCause: RuntimeException = java.lang.RuntimeException: EOF before record complete
val bpf: AvroFailure = AvroFailure.BinaryParseFailed(binCause)
// bpf: AvroFailure = BinaryParseFailed(
//   java.lang.RuntimeException: EOF before record complete
// )
bpf.message
// res18: String = "input bytes didn't parse as Avro binary: EOF before record complete"

JsonParseFailed — input String didn't parse as Avro JSON wire format under the supplied reader schema. Surfaced only by the triple-input overloads; record / bytes input cannot trigger this case. Distinct from circe's general-purpose JSON parser — this is apache-avro's JsonDecoder, which expects the schema-aware Avro JSON encoding (with branch-tagged unions etc.):

val jsonCause = new RuntimeException("Expected int. Got VALUE_STRING")
// jsonCause: RuntimeException = java.lang.RuntimeException: Expected int. Got VALUE_STRING
val jpf: AvroFailure = AvroFailure.JsonParseFailed(jsonCause)
// jpf: AvroFailure = JsonParseFailed(
//   java.lang.RuntimeException: Expected int. Got VALUE_STRING
// )
jpf.message
// res19: String = "input string didn't parse as Avro JSON: Expected int. Got VALUE_STRING"

UnionResolutionFailed — walker reached a union<...> value but none of the candidate branches matched the runtime type. The schema-declared alternatives ride along so the caller can route on the mismatch. Schema-driven; no JSON analogue:

val urf: AvroFailure = AvroFailure.UnionResolutionFailed(
  branches = List("null", "long"),
  step    = PathStep.UnionBranch("long"),
)
// urf: AvroFailure = UnionResolutionFailed(
//   branches = List("null", "long"),
//   step = UnionBranch("long")
// )
urf.message
// res20: String = "union resolution failed at UnionBranch(long) (branches: null, long)"

BadEnumSymbol — walker reached an enum value whose runtime symbol isn't a member of the schema's declared symbol set. Schema-driven; no JSON analogue. Reserved for the enum-aware walker hooks:

val bes: AvroFailure = AvroFailure.BadEnumSymbol(
  symbol = "MAGENTA",
  valid  = List("RED", "GREEN", "BLUE"),
  step   = PathStep.Field("color"),
)
// bes: AvroFailure = BadEnumSymbol(
//   symbol = "MAGENTA",
//   valid = List("RED", "GREEN", "BLUE"),
//   step = Field("color")
// )
bes.message
// res21: String = "bad enum symbol 'MAGENTA' at Field(color) (valid: RED, GREEN, BLUE)"

Pattern-match on the case to route to per-cause log / metric streams; the structured PathStep is the same shape across every case so a single step accessor in user code is enough for "which cursor position refused":

def route(chain: cats.data.Chain[AvroFailure]): List[String] =
  chain.toList.map {
    case AvroFailure.PathMissing(step)            => s"miss:    $step"
    case AvroFailure.NotARecord(step)             => s"shape:   $step (not record)"
    case AvroFailure.NotAnArray(step)             => s"shape:   $step (not array)"
    case AvroFailure.IndexOutOfRange(step, n)     => s"bounds:  $step (size=$n)"
    case AvroFailure.DecodeFailed(step, c)        => s"decode:  $step: ${c.getMessage}"
    case AvroFailure.BinaryParseFailed(c)         => s"bytes:   ${c.getMessage}"
    case AvroFailure.JsonParseFailed(c)           => s"json:    ${c.getMessage}"
    case AvroFailure.UnionResolutionFailed(bs, s) => s"union:   $s (branches: ${bs.mkString(",")})"
    case AvroFailure.BadEnumSymbol(sym, valid, s) => s"enum:    $s '$sym' valid=${valid.mkString(",")}"
    case AvroFailure.UnsupportedSpanStep(step)    => s"span:    $step (no byte span)"
    case AvroFailure.NotConfluentFramed(reason)   => s"framing: $reason"
    case AvroFailure.SchemaResolutionFailed(id, c) => s"resolve: id $id: ${c.getMessage}"
    case AvroFailure.SchemaMismatch(id, w, r)      => s"drift:   id $id (writer=$w reader=$r)"
    case AvroFailure.EncodeFailed(c)               => s"encode:  ${c.getMessage}"
    case AvroFailure.ResolveFailed(c)              => s"resolve: ${c.getMessage}"
  }

Bytes / String / record input — parse on the fly

For plain binary payloads the prism itself is the whole story: .modify / .replace / .getOption take Array[Byte] and give Array[Byte] back, splice-style, with silent pass-through on any failure. When you want DIAGNOSTICS — or your input is a String (Avro JSON wire format) or an already-parsed IndexedRecord — the record face steps in: every edit and read method behind .record (on prisms and traversals alike) accepts IndexedRecord | Array[Byte] | String as the source. Bytes parse through apache-avro's BinaryDecoder under the reader schema cached on the prism; strings go through apache-avro's JsonDecoder. Parse failures surface through the same AvroFailure accumulator as every other failure mode.

The triple-input shape is the streaming-pipeline diagnostic tier: Kafka consumers receive Array[Byte], REST handlers receive String, and intermediate stages pass parsed IndexedRecord forward — one optic surface, three call sites.

import java.io.ByteArrayOutputStream
import org.apache.avro.io.EncoderFactory
import org.apache.avro.generic.GenericDatumWriter

def toBinary(rec: GenericRecord): Array[Byte] =
  val out = new ByteArrayOutputStream()
  val encoder = EncoderFactory.get().binaryEncoder(out, null)
  val writer = new GenericDatumWriter[GenericRecord](rec.getSchema)
  writer.write(rec, encoder)
  encoder.flush()
  out.toByteArray

val incomingBytes: Array[Byte] = toBinary(record)
val upperName = codecPrism[Person].field(_.name).record.modify(_.toUpperCase)
// Happy path: parsed, modified, Ior.Right.
upperName(incomingBytes).map(
  _.asInstanceOf[GenericRecord].get("name").toString
)
// res22: Ior[Chain[AvroFailure], String] = Right("ALICE")

// Bad bytes: Ior.Left(Chain(AvroFailure.BinaryParseFailed(_))).
upperName(Array(0.toByte))
// res23: Ior[Chain[AvroFailure], IndexedRecord] = Left(
//   Singleton(BinaryParseFailed(java.io.EOFException))
// )
// Same prism, Avro JSON string input.
val incomingJson: String = """{"name":"Alice","age":30,"address":{"street":"Main St","zip":12345}}"""
// incomingJson: String = "{\"name\":\"Alice\",\"age\":30,\"address\":{\"street\":\"Main St\",\"zip\":12345}}"
upperName(incomingJson).map(
  _.asInstanceOf[GenericRecord].get("name").toString
)
// res24: Ior[Chain[AvroFailure], String] = Right("ALICE")

Handing in an IndexedRecord directly still works unchanged — the widened (IndexedRecord | Array[Byte] | String) => _ signature is a supertype of the parsed-input shape, and the parse cost is zero when the input is already a record.

On the *Unsafe surface, unparseable bytes / strings fall back to a synthetic empty record built from the reader schema — there's no meaningful "input unchanged" semantic for bytes that aren't Avro, and the whole point of *Unsafe is to drop failure detail. Callers who need parse diagnostics stay on the default Ior-bearing surface.

Schema discipline — the byte walk trusts the reader schema absolutely

The byte-carried optics perform no writer/reader schema resolution: locate consumes bytes purely by the prism's cached reader schema. Three consequences to respect in a pipeline:

Mixed-schema topics therefore need a resolving decode per payload (the record face with the correct writer schema), not the byte walk. The laws also hold up to canonical re-encoding of the focused slice: .modify re-encodes the focus, so spec-legal but non-canonical encodings (byte-sized array blocks, non-minimal varints) come back canonicalised — byte-for-byte identity is guaranteed only for payloads from conformant writers.

Confluent-framed payloads — the header is its own Prism

The Confluent strip + writer-resolve + fingerprint-gate is decode-agnostic and lives before the normal optic: it hands you the header-stripped, writer-resolved, fingerprint-gated body bytes, and you decode them however you own it — kindlings (codecPrism[A]), vulcan Codec.fromBinary, a generic-record → JSON walk, anything. Two shapes, same core:

ConfluentWire.confluent is a composable eo Prism Array[Byte] (framed) ↔ Array[Byte] (body). Drop it in front of any byte optic with .andThen:

import dev.constructive.eo.avro.ConfluentWire

// Your registry client / cache, resolved to a synchronous lookup.
val schemaById: ConfluentWire.SchemaById = id => registryCache(id)

val cf = ConfluentWire.confluent(schemaById, readerSchema, frameId = readerSchemaId)

cf.andThen(codecPrism[Person].field(_.name)).getOption(framedBytes) // Option[String]
cf.getOption(framedBytes)  // Option[Array[Byte]] — the resolved body, decode it yourself

getOption runs strip + resolve + gate and classifies by Avro parsing-canonical-form fingerprint (SchemaNormalization.parsingFingerprint64, computed once at construction for the reader): on a writer == reader fingerprint the body is byte-identical under both schemas and passes through; on a mismatch it is None (no silent misread). reverseGet re-frames via attach under frameId — only touched by modify / replace; a read-only pipeline never calls it.

When you want the reason rather than None, the one-shot primitive returns it:

ConfluentWire.resolve(framedBytes, schemaById, readerSchema)
// Either[AvroFailure, Array[Byte]] — Right(body), or
// NotConfluentFramed / SchemaResolutionFailed / SchemaMismatch(writerFp, readerFp)

confluent / resolve gate — they refuse (SchemaMismatch) when the writer and reader schemas differ. When you'd rather translate the drift than refuse, use the resolving reader below. The hook is synchronous by design — eo ships no registry client and wraps no effect, so the caller owns the cache.

The no-hassle resolving reader — ConfluentWire.reader

For a Kafka consumer, the one-call form drops straight into an fs2 Stream.evalMap. It strips the header, looks the read schema up by id (the schema the bytes were written under — effectfully), and resolves it into your type via Avro's ResolvingDecoder (reorder / default / promotion / aliases) rather than gating:

import cats.MonadThrow
import org.apache.avro.Schema
import dev.constructive.eo.avro.ConfluentWire

// Your registry client, resolved to an effectful lookup (id → read schema).
val schemaById: Int => F[Schema] = id => registry.fetch(id)

val read: Array[Byte] => F[Person] = ConfluentWire.reader[F, Person](schemaById)

// in a pipeline:
stream.evalMap(read)   // Stream[F, Array[Byte]] => Stream[F, Person]

Naming is optic-centric: readSchema is the schema the bytes are read in (apache-avro's writer schema), writeSchema the shape they resolve into / are written under (apache-avro's reader schema).

For a single-schema topic or a producer, the pure per-read-schema optic is ConfluentWire.resolving[A](readSchema, frameId) — an Affine Prism (to resolve-decodes, from re-encodes + re-frames), Either[AvroFailure, Array[Byte]] on the write side.

Migrating between schema versions — AvroBridge

When two versions of a model coexist on the wire — PersonV1 and a later PersonV2 that added a field — AvroBridge.between[A, B] is an optic that reads the old bytes, lets you migrate, and writes the new bytes:

val bridge = AvroBridge.between[PersonV1, PersonV2]

bridge.getOption(v1Bytes)                              // Option[PersonV1]
bridge.modify(v1 => PersonV2(v1.name, age = 0))(v1Bytes)
//   : AvroBridge.BridgedBytes                         // = Either[AvroFailure, Array[Byte]]

Its carrier is Affine and its type is

Optic[Array[Byte],    // writer bytes (version A)
      BridgedBytes,   // = Either[AvroFailure, Array[Byte]] — reader bytes (version B), or the failure
      A, B,           // writerFocus A, readerFocus B
      Affine]

to decodes the source under the writer codec's schema (a MissgetOption None — when the bytes don't decode as A); the A ⇒ B migration is the function you hand to .modify; from re-encodes the B under the reader codec's schema. The write can itself fail (the B doesn't encode), and eo has no carrier whose from is fallible — so that outcome lives in T = BridgedBytes = Either[AvroFailure, Array[Byte]] (a single AvroFailure: EncodeFailed / BinaryParseFailed / DecodeFailed), simulating a fallible build without a new carrier.

It's directed — read = writer version, write = reader version — and bridge.reverse swaps the codecs to give the B ⇒ A bridge (AvroBridge[B, A]). A fully symmetric encoding would be a BijectionIso Optic[BridgedBytes, BridgedBytes, A, B, Direct], but we favour the directedness and don't implement it.

Each side decodes / encodes under its own exact schema, so this is an explicit, user-driven migration between two versions — distinct from Avro's automatic writer→reader compatibility resolution.

Schema sourcing — always the codec's

codecPrism[S] reads the wire schema off the in-scope AvroCodec[S] (which kindlings derives from the case-class shape). There is no explicit-schema overload: a reader schema allowed to diverge from its codec is a footgun — the byte walk would read fields by a schema the bytes weren't written under, misreading silently on structural drift.

For the streaming case where the schema arrives at runtime (an .avsc file, a Schema Registry lookup), carry it on the codec — provide a given AvroCodec[S] whose schema is the loaded one — so the read and the schema can't drift apart. For Confluent-framed, writer-vs-reader drift is handled explicitly by ConfluentWire.confluent / .resolve (above), not by swapping in a bare schema.

vulcan codecs — AvroVulcan

Every typed entry point above is keyed on eo's AvroCodec[A]. A codebase whose codecs are vulcan bridges them once instead of hand-writing an adapter per use site:

import dev.constructive.eo.avro.vulcan.given
// every in-scope vulcan.Codec[A] now serves as AvroCodec[A]:
val countL = codecPrism[ClickInfo].field(_.count)

or, named and explicit, given AvroCodec[ClickInfo] = AvroVulcan.codec. The schema is resolved once at construction (an invalid vulcan schema fails at the given site, not on the first record); encode errors throw (eo's encode is total — an encode failure under a matching schema is a codec-definition bug); decode errors surface as Left like every other AvroCodec.

vulcan is an Optional dependency of cats-eo-avro — add it to your own build to use this sub-package; avro-only users never load it.

circe bridge — AvroJson

dev.constructive.eo.avro.circe.AvroJson is the structural Avro ↔ circe bridge: it moves between an Avro generic runtime value and a circe Json document with no case class in the middle. AvroJson.record(schema) is a lawful Prism[Json, IndexedRecord] per schema — getOption is the strict schema-guided parse (misses on any shape the schema does not pin), reverseGet the total structural walk back to Json. Around it sits the codec diagonal family — valuePrism / bytesPrism / recordPrism — tearing generic values / payload bytes / records into a typed A and mending back out as Json. The walk is defined by the wire shape, so logical types render their runtime shape: a timestamp-millis Instant is a long at runtime and renders as Json.fromLong, not the ISO-8601 string a circe Encoder[Instant] would emit.

Like vulcan above, circe is an Optional dependency of cats-eo-avro — add circe-core to your own build to use this sub-package. A failing io.circe import at compile time means your build lacks that dependency, not that the module is broken.

Ignoring failures (the *Unsafe escape hatch)

For Kafka consumers and other hot-path call sites where the caller has measured and knows they don't want the Ior allocation, every default method has a sibling *Unsafe variant that ships the silent-pass-through hot path:

val nameP = codecPrism[Person].field(_.name).record
// nameP: AvroRecordPrism[String] = dev.constructive.eo.avro.AvroRecordPrism@4bcda38f

// Hot-path: silent pass-through on miss.
nameP.modifyUnsafe(_.toUpperCase)(stump).asInstanceOf[GenericRecord].get("name")
// res25: Object = "ALICE"

// Equivalent via the default surface:
nameP
  .modify(_.toUpperCase)(stump)
  .getOrElse(stump)
  .asInstanceOf[GenericRecord].get("name")
// res26: Object = "ALICE"

Both spellings produce the same record. The first pays nothing for diagnostics; the second gives an observable Ior at the price of one allocation. Pick the surface that matches the call-site budget — every prism / traversal class ships both.

Surface summary

AvroPrism[A] and AvroTraversal[A] themselves are the byte optics — modify(f) / replace(a) / getOption / foldMap take and return Array[Byte], silent pass-through on failure (plus sliceBytes / graftBytes on the prism for raw fragments). The Ior-bearing record surface lives behind .record:

Class Default (Ior-bearing) *Unsafe (silent)
AvroPrism[A].record modify(f): In => Ior[Chain[AvroFailure], IndexedRecord] modifyUnsafe(f)
AvroPrism[A].record transform(f): In => Ior[...] transformUnsafe(f)
AvroPrism[A].record place(a): In => Ior[...] placeUnsafe(a)
AvroPrism[A].record transfer(f): In => C => Ior[...] transferUnsafe(f)
AvroPrism[A].record get(in): Ior[..., A] getOptionUnsafe
AvroPrism[A].fields(…).record same five same five
AvroTraversal[A].record modify(f) / transform(f) / place(a) / transfer(f) / getAll(in): Ior[...] modifyUnsafe / ... / getAllUnsafe
AvroTraversal[A].fields(…).record same five same five

In = IndexedRecord | Array[Byte] | String everywhere.

Migrating a codec-first model (optics-as-evidence)

There are two ways to use this module — pick deliberately:

  1. Layer on an existing codec: keep decoding to your case class elsewhere; point a prism at the one or two hot-path fields.
  2. Replace the materialised model: the wire Array[Byte] is the data structure. codecPrism[Person] uses the in-scope AvroCodec[Person] as schema evidence only — the root decode never runs on the byte path; only the drilled leaf's codec decodes its slice. Do not look for an avro4s-style whole-record mapping API here: the point is that no Person is ever materialised.

The consuming code then follows the standard doctrine — consume via capability, construct via optic. Signatures demand the weakest capability that covers what they do; the drilled prism is the evidence, so the raw payload slots into functions that never name Avro, the optic, or the wire format:

import dev.constructive.eo.*
import dev.constructive.eo.avro.AvroPrism

// Knows NOTHING about Avro, codecs, or Array[Byte] — only that a
// String street can be rewritten inside a T.
def upgradeStreet[T](t: T)(using cm: CanModify[T, String]): T =
  cm.modify(_.toUpperCase)(t)

// The drilled prism IS the capability evidence for T = Array[Byte]
// (one optic given per (S, A) pair — the coherence rule applies):
given AvroPrism[String] = streetP
upgradeStreet(incomingBytes).length   // T = Array[Byte]; no Person materialised
// res27: Int = 18
streetP.record.getOptionUnsafe(upgradeStreet(incomingBytes))
// res28: Option[String] = Some("MAIN ST")

And the same consumer serves the mode-1 materialised model unchanged — an eo-generics lens on the case class is equally valid evidence, which is also the natural way to unit-test it without Avro fixtures.

Recursive edits — Plated[IndexedRecord]

For edits that recurse through every nested record rather than sitting at a fixed path — redact a field in every sub-record, rewrite a value at any depth — Plated[IndexedRecord] (from dev.constructive.eo.avro.given) makes the record tree a recursive self-traversal. The Plated combinators (transform, rewrite, children, universe) then walk it, stack-safely. The immediate children of a record are its directly-record-valued fields; records nested inside array / map / union fields are leftover skeleton in this version. This is the Avro mirror of Plated[Json].

Plated.everywhere[IndexedRecord] is the composable form: an ordinary Modify you can .andThen a Lens / Prism onto so a single .modify rewrites that focus at every depth — composing exactly as it does for Plated[Json]. See the circe page and the cookbook recipe for the runnable everywhere.andThen(...) shape.

When to reach for which

Task Use
Edit one leaf deep in an Avro record AvroPrism via .address.street sugar
Edit element i of an Avro array codecPrism[…].items.at(i).…
Edit every element of an Avro array codecPrism[…].items.each.… + modify
Read every element's focus codecPrism[…].items.each.… + getAll
Edit multiple fields atomically codecPrism[…].fields(_.a, _.b).modify(...)
Resolve an Option[A] / sealed-trait branch codecPrism[…].field(_.amount).union[A]
Observe why a modify was a silent no-op default Ior .modify(...) — inspect the chain
Edit Kafka payloads in place (binary in, binary out) AvroPrism.modifyUnsafe(...) on Array[Byte]
Parse + edit Avro JSON wire payloads AvroPrism.modify(...) on String
Read a Confluent-framed topic into a typed A ConfluentWire.reader[F, A](schemaById)
Compose a byte optic over Confluent-framed payloads ConfluentWire.confluent(...).andThen(codecPrism[…]...)
Migrate a payload between schema versions AvroBridge.between[A, B] + .modify
Hand-written codec with divergent schema field order .fieldNamed[B]("schema_name")
Slice / graft a field's raw encoding, decode-free sliceBytes / graftBytes (ConfluentWire.graftGated when framed)
Render an Avro record as circe Json AvroJson.record(schema)eo.avro.circe

For the Kafka end-to-end recipe (read bytes, modify, re-emit), see the Cookbook → Serdes free pipes. For the full failure-mode matrix and the per-case behaviour specs, see AvroPrismSpec and siblings in the avro/src/test/scala/ tree.