Chimney vs Vanilla Scala - Protobuf to Domain Object Mapping
Nowadays, protobuf is a very popular format to exchange data over the wire (such as communication between microservices). When working with protobuf, you’ll inevitably face the challenge of converting protobuf messages to domain objects. While this seems straightforward, the reality involves error handling, type mismatches, and boilerplate code. Let’s explore if Chimney can simplify this process.
Protobuf in Scala
In the Scala ecosystem, ScalaPB is a protocol buffer compiler (protoc) plugin for Scala. It will generate Scala case classes, parsers and serializers for your protocol buffers.
Hexagonal architecture
In a hexagonal architecture, the domain layer should not depend on protobuf. That’s why we need to map generated case classes to domain objects.
A classic issue when converting between two type systems is impedance mismatch.
Java Type | Protobuf Representation | Notes |
---|---|---|
java.time.Instant |
google.protobuf.Timestamp |
Standard Protobuf type for timestamps; interoperable across languages. |
BigDecimal |
string |
Protobuf doesn’t have a built-in decimal type; use string for precision. |
Obviously, not every string is a valid BigDecimal, so the corollary is that not every valid proto can be mapped to a domain object.
So the mapper should handle errors.
Real-world example
Let’s consider a simple transaction in a payment system.
Here is the .proto file:
syntax = "proto3";
import "google/protobuf/timestamp.proto";
message Transaction {
string id = 1;
google.protobuf.Timestamp created_at = 2;
google.protobuf.Timestamp updated_at = 3;
Status status = 4;
string amount = 5;
string currency = 6;
enum Status {
STATUS_ACCEPTED = 0;
STATUS_EXECUTED = 1;
STATUS_REJECTED = 2;
}
}
And here is the equivalent domain object:
case class Transaction(
id: String,
createdAt: Instant,
updatedAt: Instant,
status: Status,
amount: BigDecimal,
currency: Currency
)
enum Status {
case Accepted
case Executed
case Rejected
}
enum Currency {
case EUR
case USD
}
Now that we understand the problem, let’s see how different approaches handle it. We are going to compare two implementations: one with vanilla Scala, and one with the Chimney library.
Vanilla implementation
def fromProto(proto: transaction.Transaction): Either[String, Transaction] =
for {
status <- fromProto(proto.status).toRight("unrecognized status")
amount <- toBigDecimal(proto.amount)
createdAt <- proto.createdAt.map(toInstant).toRight("empty timestamp")
updatedAt <- proto.updatedAt.map(toInstant).toRight("empty timestamp")
currency <- toCurrency(proto.currency)
} yield Transaction(
id = proto.id,
createdAt = createdAt,
updatedAt = updatedAt,
status = status,
amount = amount,
currency = currency
)
def fromProto(status: transaction.Transaction.Status): Option[Status] = {
status match {
case transaction.Transaction.Status.Unrecognized(_) => None
case transaction.Transaction.Status.STATUS_ACCEPTED =>
Some(Status.Accepted)
case transaction.Transaction.Status.STATUS_EXECUTED =>
Some(Status.Executed)
case transaction.Transaction.Status.STATUS_REJECTED =>
Some(Status.Rejected)
}
}
private def toInstant(timestamp: Timestamp) =
Instant.ofEpochSecond(timestamp.seconds, timestamp.nanos.toLong)
private def toBigDecimal(s: String): Either[String, BigDecimal] = {
Try(BigDecimal(s)).toEither.left.map(_ => s"'$s' is not a number")
}
private def toCurrency(s: String) =
Try(Currency.valueOf(s)).toEither.left.map(_ => s"'$s' is not a currency")
}
Chimney implementation
Chimney is a Scala library for data transformation.
Let’s rewrite the mapper with it.
Chimney is based on two typeclasses:
Transformer[A, B]
to represent a total transformation from A to BPartialTransformer[A, B]
to represent a partial transformation from A to B
object ChimneyTransactionMapper {
implicit val stringToBigDecimal: PartialTransformer[String, BigDecimal] =
PartialTransformer.fromFunction(BigDecimal(_))
implicit val stringToCurrency: PartialTransformer[String, Currency] =
PartialTransformer.fromFunction(Currency.valueOf)
implicit val statusToStatus: PartialTransformer[transaction.Transaction.Status, Status] =
PartialTransformer
.define[transaction.Transaction.Status, Status]
.withSealedSubtypeRenamed[
transaction.Transaction.Status.STATUS_ACCEPTED.type,
Status.Accepted.type
]
.withSealedSubtypeRenamed[
transaction.Transaction.Status.STATUS_EXECUTED.type,
Status.Executed.type
]
.withSealedSubtypeRenamed[
transaction.Transaction.Status.STATUS_REJECTED.type,
Status.Rejected.type
]
.buildTransformer
def fromProto(proto: transaction.Transaction) =
proto.intoPartial[Transaction].transform
}
Comparing the output
I’ve made a small application that runs each implementation on multiple transactions: a valid one, and the different possible error cases.
--- valid
vanilla: Right(Transaction(id,1970-01-01T00:00:00Z,1970-01-01T00:00:00Z,Accepted,1000.00,EUR))
chimney: Value(Transaction(id,1970-01-01T00:00:00Z,1970-01-01T00:00:00Z,Accepted,1000.00,EUR))
--- empty created at
vanilla: Left(createdAt: empty value)
chimney: Errors(NonEmptyErrorsChain(Error(EmptyValue,Path(List(Accessor(createdAt))))))
--- unrecognized status
vanilla: Left(status: unrecognized value)
chimney: Errors(NonEmptyErrorsChain(Error(EmptyValue,Path(List(Accessor(status))))))
--- invalid amount
vanilla: Left(amount: Character i is neither a decimal digit number, decimal point, nor "e" notation exponential mark.)
chimney: Errors(NonEmptyErrorsChain(Error(ThrowableMessage(java.lang.NumberFormatException: Character i is neither a decimal digit number, decimal point, nor "e" notation exponential mark.),Path(List(Accessor(amount))))))
--- unknown currency
vanilla: Left(currency: enum domain.Currency has no case with name: GBP)
chimney: Errors(NonEmptyErrorsChain(Error(ThrowableMessage(java.lang.IllegalArgumentException: enum domain.Currency has no case with name: GBP),Path(List(Accessor(currency))))))
--- all errors
vanilla: Left(status: unrecognized value)
chimney: Errors(NonEmptyErrorsChain(Error(EmptyValue,Path(List(Accessor(createdAt)))), Error(EmptyValue,Path(List(Accessor(status)))), Error(ThrowableMessage(java.lang.NumberFormatException: Character i is neither a decimal digit number, decimal point, nor "e" notation exponential mark.),Path(List(Accessor(amount)))), Error(ThrowableMessage(java.lang.IllegalArgumentException: enum domain.Currency has no case with name: GBP),Path(List(Accessor(currency))))))
Comparison
Chimney provides several key advantages:
- the field name is automatically included in the error to ease debugging (no risk of inconsistency)
- all errors are accumulated (as shown in “all errors” above). With the vanilla implementation, the first error stops the mapping. In production systems, showing multiple validation errors at once (rather than one-by-one) is a significant improvement for troubleshooting. Chimney’s error accumulation makes this trivial.
- no need to use for comprehension, Chimney handles the combination of errors for us
- if you have a
PartialTransformer[A, B]
, you get aPartialTransformer[Option[A], B]
that will return an error ifOption[A]
is empty for free. This is useful for timestamps that are wrapped in anOption
and more generally all objects are optional in proto3. See: frominto-an-option
Chimney also provides additional features for protobuf:
- a
Transformer
instance forcom.google.protobuf.timestamp.Timestamp
- automatic handling of
Unrecognized
case for enums. See: enum-fields
Limitations
When NOT to use Chimney:
- Simple 1:1 mappings with no validation (vanilla Scala is simpler)
- Your team prefers explicit over implicit transformations
Conclusion
Chimney shines when dealing with complex protobuf transformations that require robust error handling. While vanilla Scala works perfectly for simple cases, Chimney’s error accumulation, automatic field path tracking, and reduced boilerplate make it invaluable for production systems.
Choose Chimney when you value developer productivity and robust error handling over explicit control. The learning curve is minimal.