In Java the factory pattern usually means a class whose whole job is to call constructors: a FooFactory with a create method, often behind an interface and wired by a framework. Scala has the same need, controlling how objects come into existence, but the language gives you better tools, so the pattern rarely looks like the textbook diagram.
This page walks through the Scala forms of the pattern from the simplest to the most structured: companion object methods, smart constructors that make invalid objects impossible, abstract factories that choose an implementation, type-class factories the compiler selects for you, runtime registries for plugins, and factories that own resources. It uses Scala 3 syntax throughout, notes where Scala 2 differs, and builds one worked example: turning configuration into a validated storage client.
What a factory is for
A factory is any function that returns an instance of a type while hiding some decision from the caller. The hidden decision can be one of four things. Which concrete class to build, when the caller should only know an interface. Whether the input is valid at all, when some values must never exist. How to build it, when construction needs defaults, caching or derived fields. Or how to acquire and release what the object depends on, such as connections, threads or files.
Keeping those four apart is the main design skill here. Most factory trouble comes from one function trying to do all four, for example a constructor that validates, picks an implementation and opens a network connection, and therefore cannot be tested without a network.
Companion objects and universal apply
Every Scala class can have a companion object with the same name in the same file, and the companion can see the class's private members. That makes the companion the natural home for factories. A method named apply is called by writing the object name followed by arguments, so Temperature(3.0) can be a factory call that looks exactly like construction.
Scala 3 goes further with universal apply, which the documentation calls creator applications. Any class can be instantiated without new, even with no companion, because the compiler synthesises a constructor proxy. You therefore no longer need a hand-written apply just to drop new. Write one only when it adds something.
// Scala 3: any class can be built without `new` (universal apply / creator applications)
class Connection(host: String, port: Int)
val c1 = Connection("db", 5432)
// An explicit companion factory adds defaults, overloads or caching
final class Temperature private (val kelvin: Double)
object Temperature:
def fromCelsius(c: Double): Temperature = new Temperature(c + 273.15)
def fromFahrenheit(f: Double): Temperature = new Temperature((f - 32) * 5 / 9 + 273.15)Named factories such as fromCelsius beat overloaded constructors whenever two inputs share a type. Two Double constructors cannot be told apart, but two well-named methods can. A private constructor forces every caller through them.
Smart constructors: make invalid states unrepresentable
A smart constructor is a factory that validates and returns its result in a type that can represent failure, usually Either or Option. The constructor itself is private, so the only way to obtain a value is through the check. Code that receives a Port never re-validates it, because a bad one cannot exist.
final case class Port private (value: Int)
object Port:
def from(n: Int): Either[String, Port] =
if n >= 1 && n <= 65535 then Right(new Port(n))
else Left(s"port out of range: $n")
final case class Endpoint private (host: String, port: Port)
object Endpoint:
def from(host: String, port: Int): Either[List[String], Endpoint] =
val h = if host.nonEmpty && !host.exists(_.isWhitespace) then Right(host)
else Left(s"bad host: '$host'")
(h, Port.from(port)) match
case (Right(h), Right(p)) => Right(new Endpoint(h, p))
case (h, p) => Left(List(h, p).collect { case Left(e) => e })
// Endpoint("x", 0) // does not compile: apply is private in Scala 3
// e.copy(host = "") // does not compile: copy is private tooThe Scala 3 detail that makes this safe for case classes: if a case class constructor is private, the compiler-generated apply and copy get the same access, so neither can be used to bypass the check from outside. In older Scala 2 versions both stayed public, and the common workaround was a sealed abstract case class, which suppresses them. If you maintain Scala 2 code, check which behaviour your compiler version and flags give you rather than assuming. Case classes covers what else the compiler generates.
Endpoint.from also shows a choice you face in every smart constructor: stop at the first error or collect them all. Collecting is kinder to users editing configuration files, and the pattern scales with a validation type such as cats' ValidatedNec if you use that library. Throwing from apply is the option to avoid, because it hides failure from the type signature.
Opaque type factories
Sometimes a full class is too heavy. You want UserId to be a Long at runtime, with no boxing, but not interchangeable with every other Long at compile time. Scala 3 opaque types give exactly that, and their companion object is the factory.
object ids:
opaque type UserId = Long
object UserId:
def from(n: Long): Option[UserId] = Option.when(n > 0)(n)
extension (id: UserId) def value: Long = idInside the defining scope UserId is a Long; outside it is a distinct type, and UserId.from is the only way in. Opaque types in Scala 3 covers the scoping rules and the performance side.
Abstract factory: choosing an implementation
The classic abstract factory returns an interface and keeps the concrete class secret. In Scala the interface is a trait and the choice is usually a pattern match over a closed set of options, modelled as an enum or sealed trait. The compiler checks that the match is exhaustive, so adding a new storage kind without handling it is a compile-time warning, which you can promote to an error, not a runtime surprise.
trait BlobStore:
def put(key: String, bytes: Array[Byte]): Unit
def get(key: String): Option[Array[Byte]]
enum StoreConfig:
case Local(root: java.nio.file.Path)
case S3(bucket: String, region: String)
object BlobStore:
// cheap construction here; the client-owning form is in the Resource section below
def from(cfg: StoreConfig): BlobStore = cfg match // exhaustive: compiler checks every case
case StoreConfig.Local(root) => LocalStore(root)
case StoreConfig.S3(bucket, reg) => S3Store(bucket, reg)Callers depend on BlobStore and StoreConfig only. Tests pass a third implementation, an in-memory map, directly to the code under test, with no factory and no mocking library. Sealed traits and ADTs explains the exhaustiveness checking that makes this safe.
Type-class factories with givens
Sometimes the choice of factory depends on a type rather than a runtime value. Decoders, encoders, default values and test-data generators all work this way: you want the factory for Port when you ask for a Port. A type class expresses that, and Scala's given instances let the compiler pick the right factory at compile time.
trait Codec[A]:
def decode(s: String): Either[String, A]
object Codec:
given Codec[Int] with
def decode(s: String) = s.toIntOption.toRight(s"not an int: $s")
given Codec[Port] with
def decode(s: String) = s.toIntOption.toRight(s"not an int: $s").flatMap(Port.from)
def readEnv[A](name: String)(using codec: Codec[A]): Either[String, A] =
sys.env.get(name).toRight(s"missing $name").flatMap(codec.decode)
val port = readEnv[Port]("APP_PORT") // the compiler chooses the factory by typeIf no Codec[Port] exists, readEnv[Port] does not compile, which is a stronger guarantee than any runtime registry offers. The cost is that resolution happens in the compiler, and the rules for where it looks matter when instances live in different places. See Scala 3 givens and implicit resolution for how the search works and how to keep it predictable.
Runtime registries for plugins
When the set of implementations is open, chosen by a string in a config file or contributed by other modules, a closed enum no longer fits. A registry maps names to factory functions. Keep it immutable and build it once at startup. A mutable global map that modules register into from their own initialisers depends on class-loading order, and Scala objects initialise lazily on first access, so a module nobody has touched yet has not registered anything.
type Factory = Map[String, String] => Either[String, BlobStore]
object StoreRegistry:
private val factories: Map[String, Factory] = Map(
"local" -> (opts => opts.get("root").toRight("local needs root")
.map(r => LocalStore(java.nio.file.Path.of(r)))),
"s3" -> (opts => for
b <- opts.get("bucket").toRight("s3 needs bucket")
r <- opts.get("region").toRight("s3 needs region")
yield S3Store(b, r))
)
def build(kind: String, opts: Map[String, String]): Either[String, BlobStore] =
factories.get(kind).toRight(s"unknown store '$kind'; known: ${factories.keys.mkString(", ")}")
.flatMap(_(opts))The error message lists the known kinds, which turns a typo in production configuration into a one-line fix. For truly external plugins, the JDK's java.util.ServiceLoader discovers implementations from the classpath. Wrap it in the same function shape so the rest of the code does not care where a factory came from.
Effectful factories: construction that owns resources
Building an S3Store means creating an HTTP client with connection pools and threads that must be closed. A factory that returns a bare object leaves release to whoever remembers. In effect-system codebases the factory instead returns a description of acquire and release together. With cats-effect that is a Resource; ZIO's equivalent is a layer.
import cats.effect.{IO, Resource}
object S3Store:
def resource(bucket: String, region: String): Resource[IO, BlobStore] =
Resource.make(IO(newS3Client(region)))(client => IO(client.close()))
.map(client => S3Store.withClient(client, bucket))
// main: S3Store.resource("assets", "eu-west-1").use(store => app.run(store))Now the store cannot be used outside use, and the client is closed even if the application fails. Without an effect library, the plain-Scala equivalent is a factory that returns an object implementing AutoCloseable, used with scala.util.Using. The principle is the same: whoever acquires decides the lifetime, and the factory's type says so.
Worked example: configuration to a running store
Put the pieces together for a service that reads STORE_KIND, plus kind-specific options, from its environment. Parsing turns strings into fields with given codecs, so a non-numeric port fails with a typed message. Smart constructors validate each field, collecting every error so that the operator sees all of them in one deployment attempt. The registry maps the kind to a factory function and returns Left for unknown kinds, listing the valid ones. The chosen factory returns a Resource, and main acquires it, runs the application with a BlobStore, and releases it on shutdown.
Each layer can be tested on its own. Codecs and smart constructors are pure functions with property tests over edge values (0, 65535, 65536, empty and whitespace hosts). The registry is tested with a fake option map. The application code takes a BlobStore parameter, so its tests pass an in-memory store and never touch a factory at all. The one integration test that builds a real S3 client is the only test that needs credentials.
Failure modes
- Throwing from
applyor a constructor: the signature promises a value and delivers an exception. ReturnEitherand keep exceptions for bugs. - Validation bypass: a public case class constructor,
copyon Scala 2, or a deserialiser that writes fields via reflection. Make the constructor private and route JSON and database decoders through the smart constructor. - I/O inside construction: an object whose constructor opens connections cannot be created in tests or retried cleanly. Separate the pure configuration value from the resource that uses it.
- Initialisation-order bugs: objects that reference each other during initialisation can observe a null field, and mutable registries filled from initialisers miss modules that have not loaded yet.
- Factory sprawl: a factory for every class, each wrapping one constructor call. If the factory hides no decision, delete it; universal apply already removed
new.
Choosing the form
| Need | Form | Failure surfaces |
|---|---|---|
| Defaults, overloads, readable names | Companion methods | Compile time |
| Values that must be valid | Smart constructor with private constructor | Either at the boundary |
| Zero-cost validated wrappers | Opaque type with companion factory | Either or Option at the boundary |
| Pick implementation from a closed set | Enum or sealed trait plus match | Exhaustiveness check at compile time |
| Pick factory by type | Type class with givens | Missing instance fails compilation |
| Open, name-based plugins | Immutable registry or ServiceLoader | Runtime, at startup |
| Owns connections or threads | Resource or layer | Release guaranteed by the type |
What to do next
- List the classes in your codebase whose constructors can produce invalid values and give each a private constructor plus a smart constructor returning Either.
- Check your Scala version's behaviour for private case class constructors, and fix any copy or apply that bypasses validation.
- Route every JSON, database and config decoder through those smart constructors.
- Replace string-based implementation switches over a closed set with an enum and an exhaustive match.
- Make any runtime registry immutable, build it at startup, and list the known keys in its error message.
- Move I/O out of constructors into Resource-returning factories or AutoCloseable with Using.
- Delete factories that only wrap a single constructor call.