Skip to main content

· 9 min read

This post is inspired by the content from Brandon Rhodes on The Composition Over Inheritance Principle, which I found particularly intriguing. My aim in this post is to review and rewrite the code examples from my perspective.

Motivation

The debate between inheritance and composition is a longstanding one. While both approaches offer ways to create useful abstractions, they come with their unique challenges and benefits. The distinction, however, sometimes introduces complexities rather than simplifying our design.

A prime example of this complexity is what I refer to as the "2 x 2 problem." This issue arises with inheritance when we encounter scenarios requiring multiple functionalities or behaviors, each of which can have multiple extensions.

Consider the case of a logging system, as illustrated in the referenced article. A logger typically needs to manage two key aspects: filtering and output destination. But what happens when we require multiple filtering methods and various output destinations? The conventional inheritance-based implementation of a Logger class, as we will see, may not be the most maintainable solution:

class Logger:
def __init__(self):
self.content = []

def log(self, message):
self.content.append(message)
return message

# Filtering
class LevelFilteredLogger(Logger):
def __init__(self, level):
self.level = level
super().__init__()

def log(self, message):
if self.level in message:
return super().log(message)

class LengthFilteredLogger(Logger):
def __init__(self, length_limit):
self.length_limit = length_limit
super().__init__()

def log(self, message):
if len(message) < self.length_limit:
return super().log(message)

# Log Destination
class STDOutLogger(Logger):
def log(self, message):
print(message)
return super().log(message)

class FileLogger(Logger):
def __init__(self, filename):
self.filename = filename
super().__init__()

def log(self, message):
with open(self.filename, 'a') as f:
f.write(message + '\n')
return super().log(message)

The challenge arises when attempting to create combinations from these two dimensions. A LevelFilteredFileLogger example demonstrates this complexity:

# Combination of Filtering and Log Destination
class LevelFilteredFileLogger(LevelFilteredLogger):
def __init__(self, level, filename):
self.filename = filename
super().__init__(level)
def log(self, message):
msg = super().log(message)
if msg:
with open(self.filename, 'a') as f:
f.write(msg + '\n')
return msg

if __name__ == '__main__':
logger = LevelFilteredFileLogger('ERROR', 'log.txt')
logger.log('DEBUG: debug message')
logger.log('INFO: info message')
logger.log('ERROR: error message')

# Expected output in log.txt:
# ERROR: error message

Creating more combinations quickly becomes cumbersome without resorting to multiple inheritance. Additionally, this method of combining functionalities may sometimes fail to maintain the intended behavior.

A solution to this problem will be discussed later on. But first, let us explore a related issue and a possible approach through the adapter pattern.

Adapter Pattern

The adapter pattern proves particularly useful in scenarios where the existing abstraction cannot be modified, often encountered in legacy systems. Here, the original developer may have defined specific "filtering" and "output" functionalities. Subsequently, a requirement emerges to introduce a new functionality, necessitating a combination of this new feature with the existing ones. To illustrate this challenge more effectively, consider the following adjusted code example:

class FileHandler:
def __init__(self, filename):
self.filename = filename

def write(self, message):
with open(self.filename, 'a') as f:
f.write(message + '\n')

class FileLogger:
def __init__(self, file_handler):
self.file_handler = file_handler

def log(self, message):
self.file_handler.write(message)

class LevelFilteredLogger(FileLogger):
def __init__(self, file_handler, level):
self.level = level
super().__init__(file_handler)

def log(self, message):
if self.level in message:
super().log(message)

This scenario depicts a development path where initially, the focus was on supporting file-based logging, followed by the introduction of filtering capabilities. This approach might seem adequate initially. However, the complexity resurfaces with the need to incorporate another filtering method.

To address this using the adapter pattern, the strategy involves:

  • First, create a STDOutHandler dedicated to outputting logs to the standard output.
  • Subsequently, a STDOutAdapter is developed to bridge the STDOutHandler with the FileHandler interface expected by the FileLogger. This is crucial because, at this juncture, the FileLogger lacks the capability to interact with a STDOutHandler, and modifications to the FileLogger are undesirable.
  • The adapter effectively facilitates the substitution of FileHandler with any alternative that conforms to the required interface.
  • By employing the STDOutAdapter, it becomes feasible to integrate it within the LevelFilteredLogger.
    • Here, the STDOutAdapter is created to comply with the FileHandler interface, encapsulating a STDOutHandler. It delegates the execution of the write function to the STDOutHandler.

The code illustrating the implementation of these additional classes is as follows:

class STDOutHandler:
def log(self, message):
print(message)

class STDOutAdapter(FileHandler):
def __init__(self):
self.STDOutLogger = STDOutHandler()
super().__init__(None)

def write(self, message):
self.STDOutLogger.log(message)

class LengthFilteredLogger(FileLogger):
def __init__(self, file_handler, length_limit):
self.length_limit = length_limit
super().__init__(file_handler)

def log(self, message):
if len(message) < self.length_limit:
super().log(message)


if __name__ == '__main__':
file_handler = FileHandler('log.txt')
file_logger = FileLogger(file_handler)
file_logger.log('Hello World')

std_out_handler = STDOutAdapter()
std_out_logger = FileLogger(std_out_handler)
std_out_logger.log('Hello World')

level_filtered_file_logger = LevelFilteredLogger(std_out_handler, 'INFO')
level_filtered_file_logger.log('INFO: Hello World')
level_filtered_file_logger.log('DEBUG: Hello World')

length_filtered_std_logger = LengthFilteredLogger(std_out_handler, 10)
length_filtered_std_logger.log('Hello World')
length_filtered_std_logger.log('Hello')

# Expected output in log.txt:
# Hello World

# Expected output in console:
# Hello World
# INFO: Hello World
# Hello

This approach presents a viable solution when modifications to the existing codebase are not an option. By leveraging the adapter pattern, we've effectively minimized the necessity for multiple classes, enabling the straightforward replacement of one handler with another.

Bridge Pattern

Building on the previous discussion, the bridge pattern also emerges as an effective strategy to address the outlined challenges. This pattern comes into play when there are no constraints against modifying the original source code. Unlike the adapter pattern, which necessitates conforming to an existing, possibly ill-suited interface due to unanticipated extensions, the bridge pattern advocates for establishing necessary abstractions upfront.

In contrast to the somewhat cumbersome adaptation required to fit the FileHandler interface in the adapter pattern scenario, the bridge pattern would have us define a Handler class at the outset. This foundational class would then serve as a base for both STDOutHandler and FileHandler, each implementing the Handler class's interface methods. This design allows for the straightforward addition of new handler types, ensuring the system's extensibility and maintainability.

import abc

class Handler:
@abc.abstractmethod
def write(self, message):
pass

class STDOutHandler:
def write(self, message):
print(message)

class FileHandler:
def __init__(self, filename):
self.filename = filename

def write(self, message):
with open(self.filename, 'a') as f:
f.write(message + '\n')

With the bridge pattern's application, the Logger is designed to accommodate any Handler, thereby significantly simplifying the process of extending logging destinations. The structure of the Logger class, adhering to this pattern, would be as follows:

class Logger:
def __init__(self, handler):
self.handler = handler

def log(self, message):
self.handler.write(message)

The LevelFilteredLogger and LengthFilteredLogger would look like this:

class LevelFilteredLogger(Logger):
def __init__(self, handler, level):
self.level = level
super().__init__(handler)

def log(self, message):
if self.level in message:
super().log(message)

class LengthFilteredLogger(Logger):
def __init__(self, handler, length_limit):
self.length_limit = length_limit
super().__init__(handler)

def log(self, message):
if len(message) < self.length_limit:
super().log(message)

if __name__ == '__main__':
file_handler = FileHandler('log.txt')
file_logger = Logger(file_handler)
file_logger.log('Hello World')

std_out_handler = STDOutHandler()
std_out_logger = Logger(std_out_handler)
std_out_logger.log('Hello World')

level_filtered_std_logger = LevelFilteredLogger(std_out_handler, 'INFO')
level_filtered_std_logger.log('INFO: Hello World')
level_filtered_std_logger.log('DEBUG: Hello World')

length_filtered_file_logger = LengthFilteredLogger(file_handler, 10)
length_filtered_file_logger.log('Hello World')
length_filtered_file_logger.log('Hello')

# Expected output in log.txt:
# Hello World
# Hello

# Expected output in console:
# Hello World
# INFO: Hello World

Decorator Pattern

The decorator pattern also offers a unique perspective on addressing this issue. It encourages the definition of classes that maintain identical signatures, enabling their encapsulation within one another to facilitate stackable behavior. In the context of loggers, this approach begins with the establishment of destination-based logging classes:

class STDOutLogger:
def log(self, message):
print(message)

class FileLogger:
def __init__(self, filename):
self.filename = filename

def log(self, message):
with open(self.filename, 'a') as f:
f.write(message + '\n')

Then, we define the filtering-based logging classes to contain destination-based loggers:

class LevelFilteredLogger:
def __init__(self, logger, level):
self.logger = logger
self.level = level

def log(self, message):
if self.level in message:
self.logger.log(message)

class LengthFilteredLogger:
def __init__(self, logger, length_limit):
self.logger = logger
self.length_limit = length_limit

def log(self, message):
if len(message) < self.length_limit:
self.logger.log(message)

The strength of the decorator pattern is evident in its flexibility to combine functionalities such as LevelFiltering and LengthFiltering. This can be achieved by instantiating a LevelFilteredLogger that encapsulates an instance of LengthFiltering, thus avoiding the necessity to define a new class for this combined functionality.

if __name__ == '__main__':
file_logger = FileLogger('log.txt')
file_logger.log('Hello World')

std_out_logger = STDOutLogger()
std_out_logger.log('Hello World')

level_filtered_std_logger = LevelFilteredLogger(std_out_logger, 'INFO')
level_filtered_std_logger.log('INFO: Hello World')
level_filtered_std_logger.log('DEBUG: Hello World')

length_filtered_file_logger = LengthFilteredLogger(file_logger, 10)
length_filtered_file_logger.log('Hello World')
length_filtered_file_logger.log('Hello')

length_and_level_filtered_logger = LengthFilteredLogger(level_filtered_std_logger, 10)
length_and_level_filtered_logger.log('INFO: Hello World Again')
length_and_level_filtered_logger.log('DEBUG: Hello World')
length_and_level_filtered_logger.log('INFO: HI')

# Expected output in log.txt:
# Hello World
# Hello

# Expected output in console:
# Hello World
# INFO: Hello World
# INFO: HI

A Combination of Patterns

To synthesize the various strategies discussed, a comprehensive solution might be structured as follows:

import abc

class Filter:
@abc.abstractmethod
def should_keep(self, message):
pass

class Handler:
@abc.abstractmethod
def write(self, message):
pass

class Logger:
def __init__(self, filters: list[Filter], handlers: list[Handler]):
self.filters = filters
self.handlers = handlers

def log(self, message):
if all(f.should_keep(message) for f in self.filters):
for h in self.handlers:
h.write(message)

class LevelFilter:
def __init__(self, level):
self.level = level
def should_keep(self, message):
return self.level in message

class LengthFilter:
def __init__(self, length_limit):
self.length_limit = length_limit
def should_keep(self, message):
return len(message) < self.length_limit

class FileHandler:
def __init__(self, filename):
self.filename = filename
def write(self, message):
with open(self.filename, 'a') as f:
f.write(message + '\n')

class STDOutHandler:
def write(self, message):
print(message)

if __name__ == '__main__':
file_handler = FileHandler('log.txt')
length_file_logger = Logger([LengthFilter(10)], [file_handler])
length_file_logger.log('Hello World')

std_out_handler = STDOutHandler()
level_std_out_logger = Logger([LevelFilter('INFO')], [std_out_handler])
level_std_out_logger.log('INFO: Hello World')

length_and_level_logger = Logger([LengthFilter(10), LevelFilter('INFO')], [file_handler, std_out_handler])
length_and_level_logger.log('INFO: Hello World')

# Expected output in console:
# INFO: Hello World

Conclusion

The utility of design patterns becomes significantly clearer when contextualized with specific problems and examples. Each pattern offers a distinct approach to resolution, with certain strategies proving more effective under specific circumstances, particularly in relation to constraints like the feasibility of altering the original source code or the initial design. In summary, several key principles underlie these patterns:

  • Aggregation provides a broader range of possibilities compared to inheritance.
  • The establishment of abstractions (interfaces) facilitates easier extension of the codebase.
  • While indirection can offer valuable flexibility, it also has the potential to introduce complexity and confusion.

References

· 3 min read

Context

In software engineering, we often encounter work that is repetitive in nature. Examples of such tasks include setting up a new project, conducting a series of manual tests, or making a new release. While some tasks can be automated, reducing them to a mere click of a button or execution of a script, others are more complex and demand careful attention during their execution. Given that these tasks may not always be performed by the same individual, it's crucial to determine how to ensure correct execution every time.

Problem

Here are several issues that can arise when tasks are poorly managed:

  • The task may not be executed correctly.
  • Completion of the task may take longer than anticipated.
  • The task may become frustrating to execute.
  • The task may not be executed at all.

Solution(?)

A straightforward solution to this problem is to maintain well-documented tasks. Such documentation should detail the task comprehensively, including steps for execution, the anticipated outcome, and, if applicable, some troubleshooting guidance. To ensure the documentation remains relevant, it is essential to:

  • Ensure the documentation is easily accessible (i.e., not buried and subsequently forgotten).
  • Make the documentation simple to update (ideally without requiring approval).

Documenting tasks in the location where they are defined and assigned appears to be a viable strategy. The issue/ticket description often serves as the first and possibly the last reference point when someone is assigned a task. It is readily updateable if, while following the instructions, someone identifies an error or a more efficient method. A critical feature of this approach is the ability to clone the issue/ticket, facilitating the future repetition of the task.

However, this method has its limitations:

  • When a task is too complex to be fully described within a single issue/ticket, it can render the issue/ticket less effective due to a level of indirection to the actual documentation. Similarly, if a task evolves (e.g., changes in expectations or implementation), the issue/ticket may become outdated as discussions shift to the place of change (codebase) or instant messaging platforms.
  • Maintaining synchronized information across tasks can be challenging, especially if a task shares a prerequisite setup with several others. It becomes difficult to update all relevant tasks en masse upon recognizing a necessary change in setup.
  • A task may need to be necessarily brief if it is part of a larger project, thus lacking comprehensive information. In such cases, separate documentation may be required to provide a high-level overview of all tasks.
  • Typically, task systems do not offer the full feature set of a documentation system, such as the ability to include inline comments or facilitate real-time collaboration.

So, what's the best way to keep task descriptions up to date...?

· 3 min read

Context

Balancing the ease of implementation with the correctness of a solution is a complex trade-off. When developing a package to be used in a CI/CD pipeline for multiple repositories, I encountered the challenge of deciding how to handle the package's versioning strategy within the CI process.

Problem

Pinning the package to a specific version in the Jenkins script for each repository ensures that the CI process is stable and predictable. However, this approach necessitates manual intervention for each repository whenever a new package version is released, which can be problematic, especially considering the following pragmatic factors:

  • Diverse repositories managed by different teams, where gaining approvals for changes can be time-consuming and laborious.
  • The package is still under active development, with new versions released frequently.

On the other hand, always using the latest package version in CI pipelines simplifies updates but risks unexpected disruptions. This approach can eliminate the need for manual updates to many repositories but also introduces the risk of breaking changes, leading to failing CI pipelines across various repositories, which can have adverse consequences:

  • Unexpected disruptions for developers in their branches or PRs.
  • Resistance from developers, possibly leading to the removal or ignoring of this CI step.

Discussion

Finding a balance between the two approaches is crucial, and importantly, it requires a deeper understanding of the underlying problems and whether we can address them in a more fundamental way. Here are some hidden issues behind this problem:

  • Why do cross-team, multi-repo changes intimidate and slow down processes?
  • Are there ways to automate the creation of similar changes to multiple repositories?

For the first problem, it might be a management issue where a standard procedure can be devised to guide the process of assigning responsibilities and gaining approvals for cross-repo changes within the organization/team. For the second problem, it might require additional tooling to address the repetitive nature of the changes. It could also suggest that this configuration might benefit from more centralized control, where a single repository can manage the package version for all connected repositories.

Retrofitting

Before diving into what I would consider a better approach, I would like to discuss how we can retrofit the easy solution (always install and use the latest package version in CI pipelines):

  • Commit to backward compatibility: Avoid breaking changes at all costs.
  • Support previous x versions:
    • Maintain backward compatibility for the previous x versions.
    • Notify users of required upgrades without breaking their current setup for a reasonable period.
  • Provide upgrade support: Assist repositories in adapting before releasing breaking changes and updating the package version after new releases.

Solution

The solution I propose is to pin a specific package version in CI and upgrade only when necessary. To address the issues, I would also propose the improvement items mentioned in the discussion section:

  • To deal with the troublesome manual updates:
    • Create codemod-like tools or scripts to automate the process.
    • Revert the usage model to more centralized control, where a single repository can configure the package version and the repositories that will use this package in the CI pipeline.
  • To deal with cross-team, multi-repo changes:
    • Find out the established process for proposing and getting support for cross-repo changes, which may involve sharing the proposal in a forum/meeting, getting the owners' support, and then proceeding with the changes with known assigned liaisons for each repository.