Software Patterns - From Textbook To Reality
Introduction
You studied design patterns and accepted their value - but somehow it is difficult to remember when you last used one? Well good news, chances are you do use them! Design Patterns left the textbooks behind and escaped to reality!
The original software design patterns are pure, object-oriented and imperative. Modern programming languages offer meta programming through annotations and real-world projects use frameworks and libraries.
These software patterns might look different in the class diagram and some can be reduced to a single line of code or one annotation.
However, the rationals and tradeoffs for applying them remain valid.
As does the level of documentation and understanding that can be shared between developers by naming them.
There is a lot of information flowing if your colleague tells you that a piece of code is a Proxy
or Adapter
or Abstract Factory
.
Let us set sails and rediscover some common patterns.
The Singleton Pattern
Because which article about patterns wouldn’t start with the Singleton
.
@Bean
class X {}
// or slightly longer
@Scope("singleton")
@Bean
class X {}
With the Spring framework that is it. Bean Scopes default to Singleton and the framework cares about all the nasty thread safe initialization procedures.
Now that you know (or remember), there are a few interesting things to observe:
- the default is Singleton => from the framework designers perspective this seems to be quite a powerful pattern
- there are a lot of implications for multi-threading, shared usage, resource access, memory footprint …
- all of that knowledge is instantly transferred if your peers understand and recognize the pattern
The Observer Pattern
« Don’t call me, I call you. » The Observer Pattern is a very powerful yet simple pattern to decouple objects and runtime relationships.
Its usage scenarios include user interfaces, event handling and generally notification systems.
The naming varies, from Java’s ubiquitous Listeners
to Publisher and Subscriber
or the classic Observer
, but the intention remains the same.
Message Bus Systems
that provide topics
follow a similar purpose: They publish an event, and whoever needs it reacts to it.
But, compared to the Observer Pattern with a stronger focus on decoupling
and asynchronous
communication.
And then there are hybrids like Spring Events
which by default focus on decoupling
and synchronous
communication.
@Component
public class SpringEventListener {
@EventListener
public void doSomething(MyEvent event) {
// did something
}
}
@Service
@RequiredArgsConstructor
public class SpringEventPublisher {
private final ApplicationEventPublisher event;
private void publishSomething() {
events.publishEvent(new MyEvent());
}
}
The textbook Observer can be spotted as integral part of standard libraries & languages but in a wider sense the pattern is also present in infrastructure like JMS
or Kafka
and framework notification systems like Spring Events
:
- the Observer is always about propagating change to interested parties
- on an architectural level, this extends to decoupling; Spring Modulith provides a concise example
- finally remember the pitfall, Observers & Message Topics typically
lack order guarantees
The Chain of Responsibility Pattern
This pattern is used by frameworks to make applications more flexible and maintainable. To understand the power of this pattern let us study two examples.
First take a look at Spring Boot request flow, there are a lot of different filters for handling the security, the logging, the request handling and so on. Let us create a simple additional filter to write some additional logs when the request is handled.
public class CustomFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// Pre-process request
System.out.println("Request pre-processing in CustomFilter");
// Pass the request/response to the next filter in the chain
chain.doFilter(request, response);
// Post-process response
System.out.println("Response post-processing in CustomFilter");
}
@Override
public void destroy() {}
}
All request filters are called one after the other they create a chain of responsibilities.
The ordering of the chain can be influenced by the @Order
annotation or when you need more control by a FilterRegistrationBean
.
As we can see in the example above, each filter is calling the next filter in the chain by calling the chain.doFilter(request, response)
method.
The same principle is used in the Symfony framework
.
Symfony is dispatching the kernel.request
event.
There are several listeners like LocaleListener
, the RouterListener
or the SessionListener
registered to this event.
Let us again create a custom filter to add some logging.
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class MyRequestListener
{
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
$method = $request->getMethod();
$uri = $request->getRequestUri();
// Example to log the request method and uri
error_log("Request: $method $uri");
}
}
Next register the listener in the services.yaml
(or any other configuration file).
services:
App\EventListener\MyRequestListener:
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 0 }
To influence the chain order, we can use the priority
attribute (-255 and 255).
Both examples are implementations of the “Chain of Responsibility” pattern but in contrast to the textbooks the names are slightly different Filter
or Listener
instead of Handlers
. We also didn’t wire them explicitly. Instead, we rely on the framework to add our code to pre-existing
processing Chains
and influence the order - where necessary.
- try to spot more “Chain of Responsibility” implementations (think about logging, de/encoding, transformations)
- spot the order mechanisms, they are key to properly use the chain and utilize the pattern
- remember the Open/Closed principle, test
Handlers
in isolation and move them between use cases
The Visitor Pattern
As one of the original GoF patterns the visitor pattern has been out there for quite some time. Remember: the pattern allows us to add new functionality to existing object structures without modifications of the underlying classes. It promotes flexibility and follows the Open/Closed principle.
A common example is an extensible export functionality for a tree of objects, where each concrete visitor would implement one target format.
Most modern programming languages allow us to achieve the same thing with pattern matching
.
Good old Java does it like this
sealed interface Element permits A, B { }
for (Element e : elements) {
visitor.visit(element);
}
class ConcreteVisitor {
void visit(Element e) {
switch (e) {
case A a -> System.out.println("visiting an A");
case B b -> System.out.println("visiting a B");
}
}
}
and very similar in RUST
enum Element {A, B}
for e in &elements {
visitor.visit(e);
}
}
impl ConcreteVisitor {
fn visit(&self, e: &Element) {
match e {
Element::A => println!("visiting an A"),
Element::B => println!("visiting a B"),
}
}
}
As Nicolai Parlog points out, sealing the Java trait (or respectively using an enum in RUST) and avoiding a default clause in the pattern matching is key. This gets us compile time safety checks for new types of elements that might not be handled in the code base.
- use pattern matching to implement visitors with a bare minimum of code
- do not add default clauses and use sealed / enumerable sets of objects
- make it obvious if a pattern matcher is a visitor to give your colleagues the right clues
The Adapter Pattern
The adapter pattern is used to make two incompatible interfaces compatible. You often find the adapter pattern when you want to integrate legacy code, third party libraries or systems.
An example from PHP and the Symfony framework is the Notifier component. The Notifier component is an adapter to send notifications. The component is hiding the complexity of the different transports like email, slack, sms, and provides a simple interface to send notifications.
This shows how to send a Slack message with the Notifier component when we use the component standalone.
$transport = new SlackTransport("My-Slack-Access-Token", "My-Slack-Channel");
$chatter = new Chatter($transport);
$chatter->send(new ChatMessage("Hello World!"));
We do not need to know how the Slack API works, we just use the simple interface from the Notifier component. When we want to send the same message to Microsoft Teams, we just need to change the transport.
$transport = new MicrosoftTeamsTransport("webhookb2/...");
$chatter = new Chatter($transport);
$chatter->send(new ChatMessage("Hello World!"));
If you look at modular frameworks like Spring, adapters are everywhere
- learn to identify them and recognize extension points that can hold your own integrations
- the adapter makes it easy to exchange a third party library or system without changing the business logic
- keep the business logic clean, easy to test and maintain
The State Pattern
The State pattern allows objects to change their behavior dynamically based on their internal state. Famously it avoids screen long if - else branching by separating code following the Open/Closed and Single Responsibility Principles.
Let us look how frameworks implement the state pattern. This example of the Symfony workflow component shows how it can be used.
framework:
workflows:
article_publishing:
type: 'workflow'
supports: ['App\Entity\Article']
places:
- draft
- review
- published
transitions:
to_review:
from: draft
to: review
publish:
from: review
to: published
There are three states in the workflow: draft
, review
and published
, with the transitions to_review
and publish
defining how the object can change.
To implement the same workflow with Spring Boot, we can use the spring state machine.
@Configuration
@EnableStateMachine
public class StateMachineConfig extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states
.withStates()
.initial("DRAFT")
.state("REVIEW")
.end("PUBLISHED");
}
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal().source("DRAFT").target("REVIEW").event("TO_REVIEW")
.and()
.withExternal().source("REVIEW").target("PUBLISHED").event("PUBLISH");
}
}
With these configurations we can easily implement a state machine and see which changes of the state are allowed.
- large frameworks come with their own implementations of the state pattern; alternatively, using a fitting library this saves you lots of headaches during implementation
- using the pattern results in cleaner and more maintainable code by separating state handling from the business logic
- utilizing what is already there reduces the risk of overspilling efforts (architects love their states) and clearly communicates intention and capabilities of a concrete State Machine implementation (plus there might be fewer bugs)
Conclusion
In real enterprise source code most patterns are not codified like their textbook examples. The naming differs, frameworks and libraries provide ready to use implementations and language features and meta programming allow to shrink patterns, some even to the extend of declarative programming.
But the use cases and principles behind the patterns stand the test of time. If you are able to spot and name patterns, you can apply and communicate all your textbook knowledge about their properties, pitfalls and intent.
Software Patterns escaped the textbooks and are part of business reality!