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 B
  • PartialTransformer[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 a PartialTransformer[Option[A], B] that will return an error if Option[A] is empty for free. This is useful for timestamps that are wrapped in an Option and more generally all objects are optional in proto3. See: frominto-an-option

Chimney also provides additional features for protobuf:

  • a Transformer instance for com.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.