The Cake Pattern Is Probably Not What You Need

June 19, 2024

The cake pattern is Scala's answer to dependency injection. It uses traits and self-types to wire dependencies at compile time, no framework required. It's clever. It's type-safe. And most teams shouldn't use it.

Here's what it looks like:

trait UserRepositoryComponent {
  val userRepo: UserRepository
  class UserRepository {
    // Implementation
  }
}

trait UserServiceComponent {
  self: UserRepositoryComponent =>
  val userService = new UserService(userRepo)
  class UserService(repo: UserRepository) {
    // Implementation
  }
}

object UserModule extends UserRepositoryComponent with UserServiceComponent

The pitch is compelling: compile-time verification, explicit dependencies, modular design. The compiler catches missing or incompatible dependencies before you ship. What's not to like?

What's not to like is everything that happens after your codebase grows past a dozen components. The trait hierarchies become a maze. The boilerplate multiplies. New engineers stare at self-type annotations trying to figure out why their component won't compile. You've traded runtime simplicity for compile-time complexity, and the tradeoff usually isn't worth it.

The alternative is dead simple — constructor injection:

class UserRepository(db: Database) {
  // Implementation
}

class UserService(repo: UserRepository) {
  // Implementation
}

val db = new Database(...)
val repo = new UserRepository(db)
val service = new UserService(repo)

No traits. No self-types. No indirection. You can trace every dependency by reading the code. A new engineer understands the wiring in minutes, not hours.

"But you lose compile-time safety!" Do you? Constructor injection fails at compile time too — if you don't pass a required dependency, it won't compile. The cake pattern's real value-add is enforcing structure, not safety. And in my experience, that structure costs more than it saves.

When the cake pattern actually makes sense

I'm not saying never use it. There are cases where the overhead pays for itself:

  • Very large codebases with 50+ components where the trait structure actually helps navigate complexity
  • Teams with high turnover where the enforced structure prevents wiring mistakes during onboarding
  • Library/framework code where you're designing extension points for unknown consumers

For everything else — and that's most Scala applications — manual DI is the better default. It's simpler, faster to onboard, and easier to refactor. If your manual wiring gets unwieldy, reach for MacWire or Airframe before reaching for the cake pattern.

The bottom line

The cake pattern is a solution to a problem most teams don't have. It adds ceremony that feels productive but mostly just adds code. Start with constructor injection. If you hit a wall, then reach for more structure. You probably won't hit that wall.