zio-json serialization
zio-temporal uses zio-json as its default serialization mechanism. Unlike the previous Jackson-based integration, every type that crosses a workflow/activity/signal/query boundary must have a codec β a missing one is either a compile-time error or, at worst, a warning at the exact call site that's missing it (see Auto-registration below) β never a silently-malformed payload from an unregistered Scala module.
The recommended idiomβ
Derive a JsonCodec on every domain type that crosses a boundary. That's it β nothing else to wire up:
import zio.json.JsonCodec
import zio.temporal._
final case class PaymentRequest(customerId: String, amount: BigDecimal, currency: String) derives JsonCodec
sealed trait PaymentStatus derives JsonCodec
object PaymentStatus {
case object Pending extends PaymentStatus
case object Completed extends PaymentStatus
case class Failed(reason: String) extends PaymentStatus
}
@workflowInterface
trait PaymentWorkflow {
@workflowMethod
def pay(req: PaymentRequest): PaymentStatus
}
derives JsonCodec produces a given JsonCodec[T] on the companion; zio-temporal ships a pair of bridges that let
zio-json's generic combinators (list/option/either/β¦) see the encoder and decoder underneath. No CodecRegistry
construction is needed for this example to work end-to-end β the codecs for PaymentWorkflow's boundary types are
registered automatically the moment the interface is actually used, at the worker and client call sites described
next.
Auto-registrationβ
Every place zio-temporal already needs to know about a workflow/activity interface β because you're registering it
on a worker or creating a stub for it β also registers that interface's codecs into the client's CodecRegistry,
with no separate step:
// Worker side
ZWorker.addWorkflow[PaymentWorkflowImpl].fromClass // registers PaymentWorkflow's codecs
ZWorker.addActivityImplementation(new PaymentActivityImpl) // registers PaymentActivity's codecs
// Client side
client.newWorkflowStub[PaymentWorkflow](options) // registers PaymentWorkflow's codecs
This is what makes the idiom above work with zero CodecRegistry wiring: ZWorkflowClientOptions.make already
carries a (initially empty) registry when the default zio-json DataConverter is in use, and each call above
mutates it in place. CodecRegistry#addInterface[I] (below) still exists and does the same walk, but calling it
explicitly is now the exception rather than the rule.
Auto-registration is non-strict: unlike addInterface, a referenced type with no summonable codec doesn't fail
the build β it emits a compiler warning and is skipped. This matters for types that are deliberately uncodec-able
(e.g. a Scala 3 union type like Int | Null, which erases to Object and is handled by a runtime fallback instead
of a registered codec) β those shouldn't block compilation just because a workflow using them was added to a
worker. If the skipped type actually gets serialized, it still fails clearly at runtime with No ZTemporalCodec registered for β¦, same as before this feature existed β auto-registration only removes boilerplate, it never
weakens the guarantee you'd get from writing addInterface yourself for a type that does need a codec.
A registry-backed client opts out entirely with withDataConverter(raw): that clears the tracked registry, so the
foreach-guarded auto-registration calls above become no-ops (see Registering raw codecs
below for the pattern this implies when using a non-default DataConverter, e.g. ProtobufDataConverter).
What compile-time errors look likeβ
If you use a type that has no codec anywhere a Temporal boundary is crossed:
No ZTemporalCodec[com.example.MyType] in scope β Temporal needs a zio-json codec to (de)serialize
com.example.MyType across workflow/activity/signal/query boundaries.
Provide one, e.g. on com.example.MyType's companion object:
final case class MyType(...) derives JsonCodec
If you explicitly call addInterface on an interface that references an uncoded type, compilation fails the same
way:
Cannot auto-register codec for type `com.example.MyType` referenced in interface `com.example.MyWorkflow`.
Reason: ...
Provide an implicit `ZTemporalCodec` for this type (typically via zio-json `JsonEncoder` + `JsonDecoder` on its
companion), then re-try `addInterface`.
If instead the same missing codec is hit through auto-registration (ZWorker.addWorkflow[...], newWorkflowStub[...],
etc.), you get a warning at that call site instead of a hard failure β same message, different severity:
[warn] Cannot auto-register codec for type `com.example.MyType` referenced in interface `com.example.MyWorkflow` β
[warn] skipping (auto-registration is non-strict).
[warn] Reason: ...
[warn] If this type is actually serialized, this will fail at runtime with `No ZTemporalCodec registered for β¦`.
[warn] Provide an implicit `ZTemporalCodec` for this type, or if this is intentional (e.g. an erased union type
[warn] with a runtime fallback), ignore this warning.
Explicit registration (addInterface)β
Auto-registration covers the common case, but calling CodecRegistry#addInterface[I] yourself is still useful
when you want:
- A fail-fast, strict check at a single call site instead of scattered warnings at every
addWorkflow/newWorkflowStubcall β useful right after buildingZWorkflowClientOptions, so a missing codec is caught at startup rather than the first time that particular workflow is touched. - A registry populated before construction, for
DataConverters that don't participate in auto-registration at all β e.g.ProtobufDataConverter.make(registry)(see Protobuf) needs its registry built upfront, since it's handed towithDataConverter, which is exactly the call that opts a client out of auto-registration.
import zio.temporal.workflow.ZWorkflowClientOptions
val clientOptions =
ZWorkflowClientOptions.make @@
ZWorkflowClientOptions.withCodecRegistry(
new CodecRegistry().addInterface[PaymentWorkflow]
)
Multiple interfacesβ
addInterface returns the registry, so chain calls β one per workflow/activity interface your worker or client uses:
val registry = new CodecRegistry()
.addInterface[PaymentWorkflow]
.addInterface[PaymentActivity]
.addInterface[NotificationWorkflow]
Registering raw codecsβ
For ad-hoc types β e.g. a List[MyType] that no interface directly exposes β register a ZTemporalCodec[T]
explicitly:
import zio.temporal.json.ZTemporalCodec
val registry2 = new CodecRegistry()
.register(ZTemporalCodec[List[PaymentRequest]])
.register(ZTemporalCodec[Map[String, PaymentStatus]])
Or, still as a single builder on ZWorkflowClientOptions:
ZWorkflowClientOptions.make @@
ZWorkflowClientOptions.withCodecs(
ZTemporalCodec[PaymentRequest],
ZTemporalCodec[List[PaymentRequest]]
)
Separate JsonEncoder / JsonDecoderβ
derives JsonCodec is the shortest path. If you prefer explicit control β e.g. you need different encoders
depending on context β define them separately:
import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder}
final case class Customer(id: String, name: String)
object Customer {
given JsonEncoder[Customer] = DeriveJsonEncoder.gen[Customer]
given JsonDecoder[Customer] = DeriveJsonDecoder.gen[Customer]
}
Unitβ
Unit is special-cased β zio-temporal ships a ZTemporalCodec[Unit] out of the box that serializes as an empty
JSON object {} and decodes any JSON to (). This mirrors what the previous Jackson-based integration did via its
BoxedUnitModule.
What changed from the Jackson-based integrationβ
- Jackson (
jackson-module-scala,jackson-datatype-jsr310,JacksonDataConverter) is gone. The wire format is now zio-json's default shape ({"Banana":{"curvature":0.5}}for sealed-trait discriminators, not Jackson's{"type":"Banana","curvature":0.5}). - Every type that crosses a Temporal boundary must have a
ZTemporalCodec[T]in scope β compile-time error otherwise. Addderives JsonCodecon the type and it's satisfied. CodecRegistry#addInterface[I]populates the runtime registry automatically from the workflow / activity interface definition. No more "I forgot to register my type" runtime surprises.- Auto-registration goes a step further:
ZWorker.addWorkflow,ZWorker.addActivityImplementation, andnewWorkflowStubnow calladdInterface's underlying macro for you, so the common case needs noCodecRegistrywiring at all β not even the singleaddInterfacecall the initial zio-json migration required. JavaTypeTag[T]has been fused intoZTemporalCodec[T]. Everywhere aJavaTypeTag[R]was previously required (ZWorkflowStub.execute[R],ZWorkflow.sideEffect,ApplicationFailure.getDetailsAs[T], etc.), aZTemporalCodec[R]is now required instead.