advanced-manufacturing-techniques
Creating Extensible Logging Frameworks with the Factory Method Pattern in Scala
Table of Contents
Why Logging Frameworks Need Extensibility
Logging is a cross-cutting concern that touches every layer of a software system. In production Scala applications, the logging backend often changes over time: a project might start with console logging during development, switch to rolling file logging in staging, and eventually integrate with a centralized log aggregation service like Logstash or Splunk in production. Without an extensible logging framework, these transitions force engineers to modify core application code each time the logging strategy changes.
The Factory Method pattern addresses this problem by separating the logging interface from the concrete logging implementation. This separation aligns with the Open/Closed Principle: the system remains open for extension (new loggers can be added) but closed for modification (existing client code does not need to change). Scala's object-oriented and functional hybrid nature makes it particularly well-suited for implementing this pattern with minimal boilerplate and strong type safety.
Understanding the Factory Method Pattern
The Factory Method pattern is a creational design pattern that defines an interface for creating an object but delegates the instantiation decision to subclasses. Unlike the Simple Factory idiom (which uses a single static method with conditional logic), the true Factory Method pattern relies on inheritance or trait-based polymorphism to let subclasses determine which concrete class to instantiate.
This pattern is especially valuable when a framework cannot anticipate the exact types of objects it must create in advance. In the context of logging, the framework knows that it needs a logger, but the specific logger type (console, file, network, database) is determined at runtime based on configuration, environment variables, or deployment context.
Key Participants in the Pattern
- Product (Logger trait): Defines the interface for objects the factory method creates.
- ConcreteProduct (ConsoleLogger, FileLogger, etc.): Implements the Product interface.
- Creator (LoggerFactory): Declares the factory method that returns a Product object. May also contain default implementation logic.
- ConcreteCreator (optional): Overrides the factory method to return specific ConcreteProduct instances.
Designing the Logger Trait Hierarchy
The foundation of any extensible logging framework is a well-abstracted interface. In Scala, traits provide a natural mechanism for defining this contract. A minimal logging interface should expose methods for common log levels while remaining generic enough to support diverse backends.
trait Logger {
def debug(message: => String): Unit
def info(message: => String): Unit
def warn(message: => String): Unit
def error(message: => String, cause: Option[Throwable] = None): Unit
}
Using by-name parameters (=> String) is a deliberate design choice: it defers message evaluation until the logger decides whether the message should actually be emitted. For performance-critical code paths where debug logging is disabled, this avoids the cost of string interpolation entirely.
Adding Log Level Filtering
A practical enhancement is to embed log level filtering directly into the trait. This prevents verbose debug messages from reaching the output when only warnings or errors are needed.
sealed trait LogLevel
case object Debug extends LogLevel
case object Info extends LogLevel
case object Warn extends LogLevel
case object Error extends LogLevel
trait Logger {
protected val level: LogLevel
def debug(message: => String): Unit = log(Debug, message)
def info(message: => String): Unit = log(Info, message)
def warn(message: => String): Unit = log(Warn, message)
def error(message: => String, cause: Option[Throwable] = None): Unit =
log(Error, message, cause)
protected def log(level: LogLevel, message: => String, cause: Option[Throwable] = None): Unit
}
This design gives each concrete logger control over its own threshold while keeping the public API consistent. A console logger might print everything, while a production file logger might suppress debug messages unless explicitly configured otherwise.
Implementing Concrete Loggers
With the trait hierarchy in place, implementing concrete loggers becomes straightforward. Each logger encapsulates its own output mechanism and respects the level-based filtering inherited from the base trait.
Console Logger
class ConsoleLogger(override val level: LogLevel = Debug) extends Logger {
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val timestamp = java.time.Instant.now
println(s"[$timestamp] [$level] $message")
cause.foreach { t =>
t.printStackTrace(System.out)
}
}
}
The ConsoleLogger is ideal for development and debugging. It outputs immediately to standard out, which makes it easy to observe log flow in real time. Adding timestamps and stack traces helps during troubleshooting without requiring any external tooling.
File Logger
class FileLogger(
filePath: String,
override val level: LogLevel = Info,
append: Boolean = true
) extends Logger {
import java.io.{BufferedWriter, FileWriter}
private val writer = new BufferedWriter(new FileWriter(filePath, append))
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val timestamp = java.time.Instant.now
val entry = s"[$timestamp] [$level] $message${cause.fold("")(t => s"\n${t.getStackTrace.mkString("\n")}")}\n"
writer.write(entry)
writer.flush()
}
def close(): Unit = writer.close()
}
The FileLogger writes to a specified path and supports configurable log level thresholds. The close() method is important for resource management: file handles must be released properly, especially in long-running applications. In a production scenario, you would likely integrate this with a resource management library or use Scala's Using construct.
Network Logger (UDP Example)
One of the strengths of the Factory Method pattern is that adding new logger types rarely requires changing existing code. A network logger that sends log entries over UDP to a central collector demonstrates this extensibility:
class UdpLogger(
host: String,
port: Int,
override val level: LogLevel = Warn
) extends Logger {
import java.net.{DatagramPacket, DatagramSocket, InetAddress}
private val socket = new DatagramSocket()
private val address = InetAddress.getByName(host)
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val payload = s"[$level] $message".getBytes("UTF-8")
val packet = new DatagramPacket(payload, payload.length, address, port)
socket.send(packet)
}
}
This logger sends UDP packets to a remote host. Because the client code depends only on the Logger trait, switching from a FileLogger to a UdpLogger requires nothing more than changing the configuration that drives the factory.
Building the Factory
The factory encapsulates the logic for selecting and instantiating the appropriate logger. In Scala, a companion object with an apply method is idiomatic and provides a clean syntax for clients.
Configuration-Driven Factory
object LoggerFactory {
sealed trait Config
object Config {
final case class Console(level: LogLevel = Debug) extends Config
final case class File(path: String, level: LogLevel = Info, append: Boolean = true) extends Config
final case class Udp(host: String, port: Int, level: LogLevel = Warn) extends Config
}
def apply(config: Config): Logger = config match {
case Config.Console(level) =>
new ConsoleLogger(level)
case Config.File(path, level, append) =>
new FileLogger(path, level, append)
case Config.Udp(host, port, level) =>
new UdpLogger(host, port, level)
}
}
This pattern uses sealed case classes to represent logger configurations. The sealed hierarchy ensures exhaustive pattern matching at compile time: adding a new logger type requires adding a new case class and a new case in the match expression. The compiler warns if a case is missing, which reduces runtime errors.
Environment-Based Factory
In many deployments, the logging configuration is determined by environment variables rather than code-level configuration. A factory that reads environment variables can simplify deployment across different environments:
object LoggerFactory {
def fromEnvironment(): Logger = {
val loggerType = sys.env.getOrElse("LOGGER_TYPE", "console").toLowerCase
val level = sys.env.get("LOG_LEVEL").map(parseLevel).getOrElse(Info)
loggerType match {
case "console" => new ConsoleLogger(level)
case "file" =>
val path = sys.env.getOrElse("LOG_FILE", "application.log")
new FileLogger(path, level)
case "udp" =>
val host = sys.env.getOrElse("LOG_HOST", "localhost")
val port = sys.env.get("LOG_PORT").map(_.toInt).getOrElse(514)
new UdpLogger(host, port, level)
case other =>
System.err.println(s"Unknown logger type: $other, falling back to console")
new ConsoleLogger(level)
}
}
private def parseLevel(s: String): LogLevel = s.toLowerCase match {
case "debug" => Debug
case "info" => Info
case "warn" => Warn
case "error" => Error
case _ => Info
}
}
This approach is particularly useful in containerized environments where environment variables are the primary configuration mechanism. The factory becomes a single point of change for logging configuration across all services.
Using the Logging Framework
Client code interacts exclusively with the Logger trait. This decoupling means that the rest of the application has no compile-time dependency on any concrete logger implementation.
Basic Usage
val logger: Logger = LoggerFactory(LoggerFactory.Config.Console(Debug))
logger.debug("Entering method computeResults")
logger.info("Processing completed successfully")
logger.warn("Disk space below threshold")
logger.error("Connection refused", Some(new RuntimeException("timeout")))
Injecting into Classes
For larger applications, injecting the logger through constructor parameters keeps the design clean and testable:
class DataService(logger: Logger, database: Database) {
def fetchUser(id: String): Option[User] = {
logger.debug(s"Fetching user with id: $id")
val result = database.queryUser(id)
result match {
case Some(user) =>
logger.info(s"Found user: ${user.name}")
Some(user)
case None =>
logger.warn(s"User not found: $id")
None
}
}
}
In this pattern, DataService has no knowledge of whether logging goes to console, file, or over the network. The factory creates the appropriate logger at the application entry point and wires it into the service hierarchy.
Testing with the Factory Method Pattern
One of the practical benefits of this design is testability. Because the factory creates loggers based on configuration, a test can inject a special logger that captures log output for assertion purposes.
class TestLogger extends Logger {
val messages: scala.collection.mutable.ListBuffer[(LogLevel, String)] =
scala.collection.mutable.ListBuffer.empty
override val level: LogLevel = Debug
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
messages += ((level, message))
}
}
// In tests:
val testLogger = new TestLogger()
val service = new DataService(testLogger, mockDatabase)
service.fetchUser("42")
assert(testLogger.messages.exists {
case (Info, msg) if msg.contains("Found user") => true
case _ => false
})
This pattern eliminates the need for mocking frameworks for logging concerns. The TestLogger implements the same trait as production loggers, so the behavior verification is type-safe and straightforward.
Comparison with Alternative Approaches
The Factory Method pattern is not the only way to achieve extensible logging in Scala. Understanding the trade-offs with other approaches helps clarify why Factory Method is often the right choice for production systems.
Simple Factory Idiom
Many Scala projects start with a simple object Logger { def apply(...): Logger = ... } that returns a logger based on a string parameter. While simpler to implement, this approach does not scale well: every new logger type requires modifying the factory function, and the central logic can become a maintenance bottleneck.
Dependency Injection Frameworks
Frameworks like Guice or MacWire can wire loggers automatically. However, they introduce additional complexity and runtime overhead that is often unnecessary for a cross-cutting concern like logging. The Factory Method pattern provides similar flexibility without requiring a DI framework.
Functional Logger Combinators
A purely functional approach might represent loggers as functions LogLevel => String => IO[Unit]. This works well in libraries like cats-effect but adds a dependency on effect types that may be inappropriate for projects that do not already use functional effect systems.
The Factory Method pattern occupies a pragmatic middle ground: it is more structured than a simple conditional factory but less invasive than a full DI framework or functional effect system.
Advanced Extensions
Once the basic factory infrastructure is in place, several advanced features can be added with minimal code changes.
Composite Logger
A composite logger delegates to multiple loggers simultaneously. This is useful for scenarios where the same log message must be written to both a file and a monitoring dashboard:
class CompositeLogger(loggers: Seq[Logger], override val level: LogLevel = Debug) extends Logger {
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
loggers.foreach(_.log(level, message, cause))
}
}
The factory can create composite loggers by accepting a sequence of configurations. The client code still sees a single Logger instance.
Async Logger
Blocking I/O in loggers can degrade application performance. An async logger wraps an existing logger and delegates writes to a dedicated thread pool:
class AsyncLogger(underlying: Logger, executor: scala.concurrent.ExecutionContext) extends Logger {
override val level: LogLevel = underlying.level
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val msg = message // evaluate now, before async boundary
executor.execute(() => underlying.log(level, msg, cause))
}
}
This wrapper implements the same Logger trait, so it can be inserted transparently by the factory without any changes to client code.
Best Practices and Common Pitfalls
Building an extensible logging framework with the Factory Method pattern is straightforward, but several practices improve the result in production systems.
Prefer Sealed Type Hierarchies for Configuration
Using sealed traits or case classes for logger configuration ensures that the pattern match in the factory is exhaustive. This shifts errors from runtime to compile time, which reduces surprises in production.
Manage Resources Explicitly
Loggers that hold resources (file handles, network sockets, thread pools) must provide a mechanism for cleanup. Consider making loggers extend AutoCloseable and using try-with-resources or Scala's Using to ensure proper cleanup.
Avoid Premature Optimization
Many logging frameworks optimize for throughput by batching writes or using lock-free data structures. Start with simple implementations and optimize only after profiling reveals that logging is a bottleneck. The factory abstraction makes it easy to swap a slow logger for a faster one later.
Keep the Trait Minimal
Resist the temptation to add convenience methods to the Logger trait. A minimal interface is easier to implement and test. Domain-specific formatting or filtering logic can be added as extension methods or wrapper loggers.
External Resources for Deeper Learning
- Scala Design Patterns by Ivan Nikolov — A comprehensive guide that covers the Factory Method pattern alongside other structural and creational patterns in Scala context.
- Inversion of Control Containers and the Dependency Injection Pattern by Martin Fowler — Explains the relationship between factories and dependency injection, helping clarify when each approach is appropriate.
- Centralized Logging Best Practices by Loggly — Discusses production logging strategies that complement the extensible frameworks discussed here.
Conclusion
The Factory Method pattern in Scala provides a clean, extensible foundation for building logging frameworks that adapt to changing requirements. By depending on a trait rather than concrete classes, application code remains decoupled from the specifics of log output, making it possible to add new logger types, change logging destinations, and introduce performance optimizations without rewriting existing logic.
The pattern scales from small projects with a single console logger to large distributed systems that route log entries through multiple channels simultaneously. Combined with Scala's sealed hierarchies and by-name parameters, the Factory Method pattern delivers both flexibility and safety in equal measure.