Typed Domain Modeling in Scala Has a Tax. kebs Library Eliminates It.

0 Developer Productivity
Katarzyna Kozłowska

Katarzyna Kozłowska

Sales & Marketing Specialist

Typed domain modeling in Scala solves a real problem: when domain objects hold raw Long and String values, the compiler can’t catch the class of bugs where the wrong ID ends up in the wrong place. Making the types structurally distinct forces those errors to the surface at compile time, before the program runs.

Proper domain modeling means avoiding primitive obsession and giving each concept its own type. It prevents a whole class of bugs and keeps code maintainable. This matters more with AI in the loop. Every generated function is a chance to pass the wrong ID to the wrong place, and the type system is what catches it. Boilerplate shouldn’t get in the way of putting the types in, and it shouldn’t be the excuse for skipping them.Łukasz SowaManaging Partner @ Iterators

Łukasz Sowa
Łukasz Sowa

The problem is what it costs to do this properly. Each typed wrapper — UserId, Email, OrderId — requires a separate typeclass instance for every library in the stack: one for Slick, one for Circe, one for Pekko HTTP, one for ScalaCheck. These instances are mechanical. They encode no business logic. They exist only because each library defines its own typeclass hierarchy with no shared protocol between them.

We built kebs in 2016 to eliminate that tax at the source. Ten years and multiple Scala versions later, it’s still the first dependency on any Scala project at Iterators. This is a precise account of what it solves, how it works, and when to use it.

The actual cost of one wrapper type

Start concrete. One domain type:

opaque type UserId = Long

object UserId:
  def apply(value: Long): UserId = value
  extension (id: UserId) def value: Long = id

Before this type is usable across a real Scala application, you need:

// Slick: database persistence
given BaseColumnType[UserId] =
  MappedColumnType.base(_.value, UserId.apply)

// Circe: JSON serialization
given Encoder[UserId] = Encoder[Long].contramap(_.value)
given Decoder[UserId] = Decoder[Long].map(UserId.apply)

// Pekko HTTP: path segment unmarshalling
given Unmarshaller[String, UserId] =
  Unmarshaller.strict(s => UserId(s.toLong))

// ScalaCheck: test generation
given Arbitrary[UserId] =
  Arbitrary(Gen.posNum[Long].map(UserId.apply))

Five instances. Each one follows the same mechanical pattern: unwrap the value, delegate to the underlying type’s typeclass instance, rewrap. None of these implement any business logic. None of them make a decision. They exist only because Circe doesn’t know about Slick, Slick doesn’t know about Pekko HTTP, and neither of them knows about your domain model.

Now do the multiplication. A medium-sized Scala application has 30 to 50 wrapper types. At five instances per type across five libraries, you’re looking at 150 to 250 typeclass instances before you’ve written a single line of actual business logic. That’s the number. It’s calculable, not approximate.

The costs are specific:

  • Code review. Typeclass instances are easy to get subtly wrong: wrong direction on the encoder, missing a null case, wrong type parameter order. Reviewers have to check each one. There are 150 to 250 of them.
  • Bug multiplication. The Boilerplate Code: Productivity and Consistency in Software Development article identifies this directly: copying flawed code replicates errors throughout the codebase. A subtly wrong MappedColumnType pattern, copied across 20 types, becomes 20 instances of the same bug. Each one passes initial code review because reviewers pattern-match against the last correct one they saw.
  • Library upgrades. Every major library version is a migration point for every hand-written instance. Circe’s API changed between major versions. Every encoder and decoder is a potential failure point.
  • Cognitive load. The same boilerplate article argues that excessive repetitive code “decreases the mental strain necessary for information processing and problem-solving” when eliminated, and that cleaner codebases let developers maintain focus on the actual domain problem. The inverse is also true: 200-line files of mechanical instance definitions are context that engineers have to hold in working memory without gaining anything from it.
  • Onboarding. A new engineer on the project should spend their first week understanding the domain model, not decoding why UserId needs five given instances spread across three files. The Developer Productivity: Strategies and Tools for Digital Transformation article frames toolchain quality as one of the four pillars of Developer Experience, and argues it directly affects lead time. Boilerplate that a compiler could generate is toolchain friction by another name.

The situation we kept encountering at Iterators: engineers who fully understood the domain but were slowed down by the plumbing. On client projects, you don’t always have time for that slowdown. After the fifth project with the same pattern, we stopped copying instances between codebases and wrote kebs instead.

What kebs library does

kebs library eliminates these definitions through compile-time derivation. Mix in a trait, and the compiler generates the necessary typeclass instances automatically — before the program runs, with no runtime reflection.

// Without kebs: five given instances per type, repeated for every type
opaque type UserId = Long
object UserId:
  def apply(value: Long): UserId = value
  extension (id: UserId) def value: Long = id

given BaseColumnType[UserId] = ...
given Encoder[UserId] = ...
given Decoder[UserId] = ...
given Unmarshaller[String, UserId] = ...
given Arbitrary[UserId] = ...
// Repeat for Email, OrderId, CustomerId, Amount, ProductId...

// With kebs: the opaque type extends kebs-opaque's Opaque base --
// that's what gives kebs-opaque something to derive from
import pl.iterators.kebs.opaque.Opaque

opaque type UserId = Long
object UserId extends Opaque[UserId, Long]

// Slick instances derive on the profile, not on an arbitrary class --
// KebsSlickSupport has a self-type of JdbcProfile
object MyProfile extends H2Profile with KebsSlickSupport {
  override val api: KebsApi = new KebsApi {}
  trait KebsApi extends API with KebsBasicImplicits with KebsValueClassLikeImplicits
}
import MyProfile.api._

class UserRepository extends KebsCirce with KebsPekkoHttpUnmarshallers with KebsArbitrarySupport:
  // Encoder[UserId], Decoder[UserId], Unmarshaller[String, UserId], Arbitrary[UserId]
  // all derived automatically via kebs-opaque
  // BaseColumnType[UserId] comes from MyProfile.api, imported above

Add a new domain type. It’s covered by the same traits without any additional code. The cost of introducing a wrapper type drops to the type definition itself.

How the derivation works. In Scala 3, opaque types are transparent within their companion object — the compiler knows that UserId is Long inside object UserId. kebs-opaque exploits this, but it needs the companion object to extend kebs-opaque’s Opaque[UserId, Long] base rather than hand-rolling apply/extension methods — that’s the hook kebs-opaque’s given instances attach to. Once it’s there, kebs-opaque bridges from Encoder[Long] to Encoder[UserId] without any macro expansion. The compiler resolves the chain at compile time through Scala 3’s standard given synthesis. No runtime reflection, no bytecode manipulation, no overhead at runtime.

For Scala 2, kebs library uses Shapeless-based derivation. The mechanism is different — Shapeless represents case classes and wrapper types as HLists and derives instances by traversing the generic representation — but the guarantee is the same: all work happens at compile time, and the generated instances are equivalent to what you’d write by hand.

The practical consequence of compile-time derivation: if a type isn’t supported, you get a compile error, not a runtime failure. That’s the right place for this category of error.

What the compiler actually sees

Here’s what happens without a typeclass instance and without kebs library:

import pl.iterators.kebs.opaque.Opaque

opaque type UserId = Long
object UserId extends Opaque[UserId, Long]

// Somewhere in your application, with no KebsCirce in scope:
val json = UserId(42).asJson  // Circe's extension method

The compiler reports at build time:

No given instance of type io.circe.Encoder[UserId] was found for parameter encoder of method asJson

This is a compile error. You find out immediately, before the program runs. Without typed wrappers, you’d be passing raw Long values, the compiler would accept them, and the wrong ID would produce a runtime failure — or worse, silently succeed with incorrect data.

kebs library doesn’t change this compile-time guarantee. It just means you don’t have to write the instance to satisfy it.

On compile time. kebs Scala 3 uses given synthesis through transparent inline methods, not traditional macro expansion. For projects with 30-50 opaque types, incremental compile overhead is modest — on the order of what you’d expect from any reasonably sized given instance chain, not the compile-time regressions associated with macro-heavy Shapeless derivation in Scala 2. For Scala 2 projects using kebs-tagged or kebs-slick, the Shapeless derivation does add some compile time; this is a known tradeoff of the approach, and the same tradeoff you’d pay with any Shapeless-based derivation library.

What kebs library covers

kebs library ships 19 integration modules across the modern Scala stack:

Databases: Slick (including PostgreSQL array and hstore support), Doobie

JSON: Circe (with snake_case and CapitalizedCase variants), Spray JSON (with support for case classes with more than 22 fields), Play JSON

HTTP: Pekko HTTP, http4s, http4s-stir, Akka HTTP (Scala 2 only; see note below)

Config and testing: PureConfig, ScalaCheck

Types and schema: Opaque types (Scala 3), native Scala 3 enums, tagged types, Enumeratum, JSON Schema, baklava, and pre-built converters for java.time, UUID, and URI

Scala 2.13 and Scala 3, MIT license, actively maintained since 2016.

On Akka vs. Pekko. In 2022, Lightbend changed the Akka license from Apache 2.0 to Business Source License (BSL). The Scala ecosystem responded with Apache Pekko — a fully open-source fork of Akka. kebs library tracks this migration: kebs-pekko-http is the current module and supports both Scala 2.13 and Scala 3. kebs-akka-http remains available for projects that haven’t migrated yet, but is limited to Scala 2.

Spray JSON and the 22-field limit. Spray JSON derives codecs for case classes using Scala’s tuple types. Scala 2 tuple types are limited to 22 elements, which means Spray JSON fails to derive codecs for case classes with more than 22 fields — either a compile error or a runtime StackOverflow depending on the version. This limitation is real and teams hit it when domain objects grow. kebs-spray-json works around it by using Shapeless’s HList derivation instead of tuples, which has no field count limit.

ScalaCheck and type safety in tests. The ScalaCheck integration is worth specific attention because it changes the safety guarantee of property-based tests. Without kebs, deriving Arbitrary[UserId] and Arbitrary[CustomerId] by hand requires explicitly constructing both from Gen[Long]. In practice, hand-written instances often use the same generator strategy for both — which means ScalaCheck may generate UserId(42) and CustomerId(42) from the same seed. Properties designed to catch “wrong ID in wrong place” bugs will pass even when the types are swapped, because the values are identical. With kebs library, each opaque type gets an Arbitrary derived from its underlying type independently, preserving the structural distinction that typed wrappers were introduced to enforce.

Doobie. The kebs-doobie module works analogously to the Slick integration: mix in KebsDoobie and Get, Put, and Meta instances for your domain types derive automatically. The http4s + Doobie stack has become a common Scala 3 combination, and kebs library covers both sides of it.

Three approaches to wrapping primitive types

Typed wrapper types — UserId instead of Long, Email instead of String — exist because the Anemic Domain Model: The Silent Drain on Your Software problem has a type-system solution. That article identifies the core failure: when domain objects hold raw data without behavior or structural constraints, “business logic gets scattered throughout the codebase,” forcing developers to navigate fragmented code to understand or modify functionality. Typed wrappers are one layer of the fix: when UserId and CustomerId are structurally distinct types, passing one where the other is expected is a compile error, not a runtime surprise. kebs library supports three approaches to building that typed layer, with different trade-offs.

Opaque types (Scala 3, recommended for new projects)

opaque type UserId = Long
object UserId:
  def apply(value: Long): UserId = value
  extension (id: UserId) def value: Long = id

opaque type Email = String
object Email:
  def apply(value: String): Email = value
  extension (e: Email) def value: String = e

Zero runtime overhead, clean syntax, no library dependency for the type definition itself. At compile time, UserId and Email are distinct types; passing one where the other is expected is a type error. The extension method above is the idiomatic Scala 3 way to expose the underlying value — if you’re writing every typeclass instance by hand.

The problem is cross-library integration. Scala 3’s built-in derives mechanism generates typeclass instances for a specific typeclass defined with derives support — it doesn’t span independent libraries. After defining your opaque type as above, you’d still need separate given instances for Slick, Circe, and Pekko HTTP. kebs-opaque handles that cross-library derivation automatically — but it needs a hook to derive from, so the companion object extends kebs-opaque’s Opaque[OpaqueType, Underlying] base instead of hand-rolling apply/extension:

import pl.iterators.kebs.opaque.Opaque

opaque type UserId = Long
object UserId extends Opaque[UserId, Long]

opaque type Email = String
object Email extends Opaque[Email, String]

Opaque gives you UserId(42) for construction (throwing on a failed validate), UserId.from(42) for an Either-returning variant, and .unwrap to get back the underlying value — that’s what kebs-opaque reads to derive Encoder[UserId], BaseColumnType[UserId], and the rest. The hand-rolled apply/extension version above compiles fine on its own, but it gives kebs library nothing to derive from.

kebs-enum solves an analogous problem for native Scala 3 enums: the language’s built-in enum mechanism doesn’t participate in Enumeratum’s typeclass hierarchy, so library integrations that expect Enumeratum don’t work with native enums out of the box. kebs-enum handles the derivation.

Value classes (Scala 2/3 compatible)

case class UserId(value: Long) extends AnyVal
case class Email(value: String) extends AnyVal

Simple, explicit, and compatible across Scala 2.13 and Scala 3. Zero runtime overhead in most cases (the JVM may box in certain contexts). For greenfield Scala 3 projects, opaque types are more idiomatic; for codebases that span both versions, value classes keep the code portable without conditional compilation.

Tagged types (Scala 2)

type UserId    = Long @@ UserIdTag
type ProductId = Long @@ ProductIdTag

Tagged types are Long values with a phantom type attached. At runtime, they’re identical to Long — no boxing, no wrapping. At compile time, UserId and ProductId are incompatible even though both are backed by Long. The type safety is structural, enforced by the type system.

The problem with tagged types is typeclass propagation. The compiler has Encoder[Long], but that doesn’t automatically become Encoder[UserId @@ UserIdTag]. kebs library handles this: its tagged type module derives the full set of typeclass instances for tagged types the same way it does for value classes — via the ValueClassLike/tagged-type bridge, which covers common underlying types like Long, String, UUID, BigDecimal, and Int.

Choosing between them:

SituationApproach
New project, Scala 3Opaque types
Existing codebase spanning Scala 2.13 and 3Value classes
Existing Scala 2 code with tagged typesKeep tagged types; kebs covers them
You need validation logic inside the type (Email must match a pattern)iron or baklava alongside kebs

Why this isn’t already solved by Shapeless, Magnolia, or iron

These tools solve related but different problems.

Shapeless and Magnolia let you derive typeclass instances within a single library. You use Shapeless to derive a Circe codec for a case class without writing the codec by hand. That’s real and useful. But derivation within a single library doesn’t span the integration layer. After using Shapeless to derive your Circe codec, you still need the Slick column mapping, the Pekko HTTP unmarshaller, and the ScalaCheck arbitrary — each one, separately. Each library has its own typeclass hierarchy. No single derivation mechanism covers all of them because there is no shared protocol between independent libraries.

Scala 3’s built-in derives mechanism is in the same category: it generates instances for a specific typeclass, one library at a time.

kebs library works one level higher. It coordinates derivation across the libraries you’re already using. The glue between Circe and Slick and Pekko HTTP is not Circe’s responsibility, not Slick’s responsibility, and not Pekko HTTP’s responsibility. It’s the application layer’s responsibility. kebs is the missing coordination layer.

iron and refined solve a different problem. They add validation constraints to types: type Email = String 😐 Match[“””^[^@]+@[^@]+$”””] ensures that a value of type Email satisfies a regex predicate at the point of construction. That’s about invariant enforcement — what values are legal. kebs is about toolchain integration — how a type participates in serialization, persistence, and testing. These are orthogonal problems. You can use iron for validation and kebs for integration, and they compose: once you have an iron-constrained type, kebs derives the library instances for it.

If your project uses Iterators’ own validation library baklava, kebs-baklava provides the same integration bridge.

A real migration

Here’s what migration actually looks like. A project has an OrderRepository with a handful of wrapper types:

// Before: 40+ lines of type definitions and given instances
opaque type OrderId = Long
object OrderId:
  def apply(v: Long): OrderId = v
  extension (id: OrderId) def value: Long = id

opaque type CustomerId = Long
object CustomerId:
  def apply(v: Long): CustomerId = v
  extension (id: CustomerId) def value: Long = id

opaque type ProductId = UUID
object ProductId:
  def apply(v: UUID): ProductId = v
  extension (id: ProductId) def value: UUID = id

opaque type Amount = BigDecimal
object Amount:
  def apply(v: BigDecimal): Amount = v
  extension (a: Amount) def value: BigDecimal = a

given BaseColumnType[OrderId] = MappedColumnType.base(_.value, OrderId.apply)
given BaseColumnType[CustomerId] = MappedColumnType.base(_.value, CustomerId.apply)
// ... and so on for Circe, ScalaCheck, Pekko HTTP...

// After: opaque types extend kebs-opaque's Opaque base instead of
// hand-rolling apply/extension -- that's the hook kebs derives from
import pl.iterators.kebs.opaque.Opaque

opaque type OrderId = Long
object OrderId extends Opaque[OrderId, Long]

opaque type CustomerId = Long
object CustomerId extends Opaque[CustomerId, Long]

opaque type ProductId = UUID
object ProductId extends Opaque[ProductId, UUID]

opaque type Amount = BigDecimal
object Amount extends Opaque[Amount, BigDecimal]

// Slick instances derive on the profile -- KebsSlickSupport has a
// self-type of JdbcProfile, so it can't be mixed into a repository directly
object MyProfile extends H2Profile with KebsSlickSupport {
  override val api: KebsApi = new KebsApi {}
  trait KebsApi extends API with KebsBasicImplicits with KebsValueClassLikeImplicits
}
import MyProfile.api._

class OrderRepository extends KebsCirce with KebsPekkoHttpUnmarshallers with KebsArbitrarySupport:
  // Encoder/Decoder/Unmarshaller/Arbitrary instances derived automatically via kebs-opaque
  // BaseColumnType instances come from MyProfile.api, imported above

The migration process is mechanical: add the dependency, mix in the traits, delete the hand-written instances, fix compilation errors. Most projects compile cleanly after the deletions. A few edge cases worth knowing:

Naming convention conflicts. If your Circe encoders produce camelCase JSON and kebs defaults conflict with an existing convention, use the variant trait: KebsCirceSnakified or KebsCirceCapitalized. The variants are independent modules you can mix selectively per repository — one repository using snake_case for database fields, another using camelCase for an external API.

Custom instance priority. When you need a hand-written instance to take precedence over the kebs-derived one (for example, a custom JSON format for a specific type), declare the custom given directly in the scope where it’s needed. Scala’s given resolution prioritizes instances in the current scope over those inherited from traits.

Types with validation logic. If a wrapper type performs validation at construction time — an Email that rejects malformed addresses — the kebs-derived Circe decoder will call apply and propagate any exception as a decode failure. This is usually the correct behavior, but worth verifying against your error-handling conventions.

The pattern we’ve seen on client projects: engineers who inherited codebases with 200-line implicit definition files, where the actual repository logic was buried at the bottom. After migration, those files shrank to the domain logic plus the trait mix-in. Code review became faster because there was less to check. Upgrades became less painful because there was less to migrate.

What kebs library doesn’t do

kebs library handles toolchain integration for types that are isomorphic to their underlying type — a UserId is always exactly one Long, an Email is always exactly one String. It doesn’t add validation logic; constructing Email(“not-an-email”) compiles and runs without error unless you add validation separately.

It doesn’t handle sum types or sealed trait hierarchies in the same automatic way — for ADTs, you write the codec once (or use Circe’s derivation), and kebs library integrates that codec with the rest of the stack.

It doesn’t control JSON field naming for case classes beyond the snake_case and CapitalizedCase variants — for custom field mappings, you write the codec by hand.

These aren’t gaps. They’re scope boundaries. kebs library does one thing well: eliminate the mechanical cross-library integration cost for wrapper types. Everything else is either another tool’s job or intentionally left to application code.

Getting started

Add the modules for the libraries you’re using:

// build.sbt
libraryDependencies ++= Seq(
  "pl.iterators" %% "kebs-slick"       % "2.1.6",
  "pl.iterators" %% "kebs-circe"       % "2.1.6",
  "pl.iterators" %% "kebs-pekko-http"  % "2.1.6",
  "pl.iterators" %% "kebs-scalacheck"  % "2.1.6",
  // Scala 3 extras:
  "pl.iterators" %% "kebs-opaque"      % "2.1.6",
  "pl.iterators" %% "kebs-enum"        % "2.1.6",
  // For Doobie + http4s stack:
  "pl.iterators" %% "kebs-doobie"      % "2.1.6",
)

Mix the traits into your repositories and services. KebsSlickSupport has a self-type of JdbcProfile, so it goes on your Slick profile object, not on a repository class:

// Slick: mix into the profile, not the repository
object MyPostgresProfile extends PostgresProfile with KebsSlickSupport {
  override val api: KebsApi = new KebsApi {}
  trait KebsApi extends API with KebsBasicImplicits with KebsValueClassLikeImplicits
}
import MyPostgresProfile.api._

// Circe + Pekko HTTP (camelCase JSON): these can mix into anything
class UserRepository(db: Database)
    extends KebsCirce
    with KebsPekkoHttpUnmarshallers:
  // Encoder/Decoder/Unmarshaller derive automatically
  // BaseColumnType comes from MyPostgresProfile.api, imported above

// Doobie + Circe (snake_case JSON, common for external APIs)
class OrderRepository(xa: Transactor[IO])
    extends KebsDoobie
    with KebsCirceSnakified:
  // Get, Put, Meta, Encoder, Decoder all derived automatically

Define your domain types. Add more. The cost stays at zero.

GitHub: https://github.com/theiterators/kebs, MIT license, Scala 2.13 and 3, actively maintained.

Where the idea came from

Iterators started writing kebs library in 2016 because we were doing the same thing on every Scala project: writing five implicit definitions per wrapper type, copying them from project to project, fixing subtle bugs in them during code review. The problem wasn’t that the definitions were hard to write. The problem was that writing them added zero value. No engineer became better at the domain problem by writing the 40th MappedColumnType for a wrapper around Long.

The library emerged from an internal decision to stop doing this by hand. We open-sourced it because we couldn’t see any reason to keep it internal. The problem isn’t specific to Iterators; it’s specific to using Scala with a typed domain model and a standard library stack.

Since 2016, kebs has tracked changes through Scala 2.11, 2.12, 2.13, and Scala 3. It has tracked Akka HTTP through the Pekko fork. It has tracked multiple major Circe and Slick versions. Every module update is a migration we handle once; projects using kebs library don’t carry that maintenance cost separately.

The maintenance philosophy is the same as the original motivation: engineers should spend time on problems that require thinking, not on patterns that a compiler can resolve.

The position

The Developer Productivity article notes that tools must “integrate seamlessly and streamline development tasks” to improve Developer Experience — and that DevEx is a direct driver of lead time and onboarding speed in DORA terms. The boilerplate article identifies the mechanism: cognitive load doesn’t just slow individual tasks, it fragments attention and extends the feedback loop between writing code and understanding whether it’s correct.

150 to 250 typeclass instances that implement no business logic are 150 to 250 opportunities for cognitive load that produces no signal. They occupy review capacity, they extend upgrade cycles, and they teach new engineers about the plumbing before they’ve learned the domain.

If you have more than 10 wrapper types in a Scala project, you’re already paying this tax. Add kebs, delete the instances, and redirect that capacity toward the problems that actually require judgment.

The broader point is worth noting. The reason kebs library exists isn’t a failure of any individual library — Circe, Slick, and Pekko HTTP are all well-designed. It’s that coordinating across independent libraries is nobody’s responsibility by default. That coordination layer has to exist somewhere. Letting it accumulate as hand-written instances in every project is the expensive default. kebs library is the alternative.