Backend Development 11 min read

How Scala Macros Simplify Protobuf‑Java ↔ Scala Case Class Conversions

This article explains a Scala‑based DSL that uses macros and implicit parameters to generate type‑safe, boiler‑plate‑free conversion code between protobuf‑java messages and Scala case classes, showing examples, builder customisation, and upcoming Scala 3 support.

GrowingIO Tech Team
GrowingIO Tech Team
GrowingIO Tech Team
How Scala Macros Simplify Protobuf‑Java ↔ Scala Case Class Conversions

Background

In GrowingIO backend development we use gRPC for microservice communication, defining Protobuf messages for each service and generating Java code with

protoc

. Scala services use case classes, so converting between protobuf‑java objects and Scala case classes becomes repetitive and error‑prone, especially with nested types.

Typical mappings (e.g.,

java.util.List

Seq

,

Timestamp

ZonedDateTime

,

Option[String]

StringValue

) exist, but a generic, type‑safe solution that minimizes boilerplate was needed.

Inspired by Play‑JSON Reader/Writer and Chimney macro‑based conversion, we created

scala-protobuf-java

, a DSL that combines Scala macros with implicit‑parameter design.

Solution Effect

Defining a case class

User

and a protobuf message

UserPB

, manual conversion requires many lines of code. With the DSL a single call

Protoable[User,UserPB].toProto(user)

performs the conversion safely and concisely.

DSL Design

The core traits are

Protoable[-S,+P]

and

Scalable[+S,-P]

, representing conversion directions.

<code>trait Protoable[-S, +P] {
  def toProto(entity: S): P
}
trait Scalable[+S, -P] {
  def toScala(proto: P): S
}</code>

Companion objects provide implicit converters for primitive and common types, such as Java boxed types,

StringValue

, and

Timestamp

ZonedDateTime

.

Macro‑Generated Code

Using the DSL we write concrete converters for

User

and

UserPB

. The macro expands the boilerplate, handling optional fields, collections, and nested types automatically.

<code>new Protoable[User, UserPB] {
  override def toProto(entity: User): UserPB = {
    val builder = UserPB.newBuilder()
    builder.setId(entity.id)
    builder.setName(entity.name)
    if (entity.phoneNumber.isDefined) {
      builder.setPhoneNumber(implicitly[Protoable[String,StringValue]].toProto(entity.phoneNumber))
    }
    builder.addAllHobbies(implicitly[Protoable[Seq[String], java.util.List[String]]].toProto(entity.hobbies))
    builder.build
  }
}

new Scalable[User, UserPB] {
  override def toScala(proto: UserPB): User = {
    User(
      id = proto.getId,
      name = proto.getName,
      phoneNumber = if (proto.hasPhoneNumber) Some(implicitly[Scalable[String,StringValue]].toScala(proto.getPhoneNumber)) else None,
      hobbies = implicitly[Scalable[Seq[String], java.util.List[String]]].toScala(proto.getHobbiesList)
    )
  }
}</code>

Builder Customisation

For field‑level rules we provide

ScalableBuilder

and

ProtoableBuilder

that let users inject lambdas, e.g., ensuring an

id

is non‑negative.

<code>val scalable = ScalableBuilder[User,UserPB]
  .setField(_.id, pb => if (pb.getId < 0) 0 else pb.getId)
  .build
scalable.toScala(...)</code>

Scala 3 Support

Scala 3 introduces cleaner implicit definitions and a new inline/Quotes macro system, which can simplify the DSL further, although some issues remain, such as protoc‑generated Java files not compiling.

DSLBackend DevelopmentProtobufType SafetyMacroScala
GrowingIO Tech Team
Written by

GrowingIO Tech Team

The official technical account of GrowingIO, showcasing our tech innovations, experience summaries, and cutting‑edge black‑tech.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.