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.
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
Userand 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
Userand
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
ScalableBuilderand
ProtoableBuilderthat let users inject lambdas, e.g., ensuring an
idis 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.
GrowingIO Tech Team
The official technical account of GrowingIO, showcasing our tech innovations, experience summaries, and cutting‑edge black‑tech.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.