Design Patterns in Dart with Code Examples. Part 1
This article has been translated from its original publication at https://habr.com/ru/articles/736364/
If you're involved in Flutter programming, you've probably encountered tasks that could be solved more efficiently and easily by using proven solution practices. This is where design patterns in Dart come in handy – templates that developers apply to solve frequently occurring problems. In two articles, the Mad Brains team will discuss 16 design patterns in Dart, and how they can be used to improve code quality and enhance development efficiency.
Singleton
The Singleton pattern is a creational design pattern. Its goal is to ensure that a class has only one instance throughout the program and provides a global access point to it.
Implementation: Hide the class constructor and create a static method/field/getter that provides access to the class instance.

Pros
- Ensures that there is only one instance of the class in the program.
- Provides a global access point to the instance.
Cons
- Has issues with threading in multi-threaded languages.
- Requires special testing tactics during unit testing.
Abstract Factory
The Abstract Factory pattern is a creational design pattern. Its goal is to create a family of related objects without specifying their concrete classes.
Implementation:
- Define common interfaces for the family of objects
- Define an interface for the abstract factory that has methods for creating each type of object in the family.
- Create a specific factory class for each family of objects, implementing the interface of the abstract factory.
Code example:
abstract class Button {
const Button();
void paint();
}
/// Specific button - Android
class AndroidButton implements Button {
const AndroidButton();
@override
void paint() => print('AndroidButton is painted');
}
/// Specific button - IOS
class IOSButton implements Button {
const IOSButton();
@override
void paint() => print('IOSButton is painted');
}
/// General interface for checkboxes
abstract class CheckBox {
const CheckBox();
void paint();
}
/// Specific checkbox - Android
class AndroidCheckBox implements CheckBox {
const AndroidCheckBox();
@override
void paint() => print('AndroidCheckBox is painted');
}
/// Specific checkbox - IOS
class IOSCheckBox implements CheckBox {
const IOSCheckBox();
@override
void paint() => print('IOSCheckBox is painted');
}
/// Abstract factory interface
abstract class GUIFactory {
const GUIFactory();
Button createButton();
CheckBox createCheckBox();
}
/// Definite factory - Android
class AndroidFactory implements GUIFactory {
const AndroidFactory();
@override
Button createButton() => AndroidButton();
@override
CheckBox createCheckBox() => AndroidCheckBox();
}
/// Definite factory - IOS
class IOSFactory implements GUIFactory {
const IOSFactory();
@override
Button createButton() => IOSButton();
@override
CheckBox createCheckBox() => IOSCheckBox();
}
// The client, utilizing the abstract factory, remains decoupled from specific object classes and can work with any variations of the object family through their abstract interfaces.
class Application {
Application(this._factory) {
_button = _factory.createButton();
_checkBox = _factory.createCheckBox();
}
final GUIFactory _factory;
late final Button _button;
late final CheckBox _checkBox;
void paint() {
_button.paint();
_checkBox.paint();
}
}
void main() {
late Application app;
app = Application(IOSFactory());
app.paint(); // Output: 'IOSButton is painted\nIOSCheckBox is painted'
app = Application(AndroidFactory());
app.paint(); // Output: 'AndroidButton is painted\nAndroidCheckBox is painted'
}
Pros
- Implements the Open/Closed Principle.
- Simplifies the replacement and addition of new families of products.
- Ensures the compatibility of products.
- Reduces client code's dependency on specific product classes.
Cons
- Complicates the program code due to the introduction of multiple additional classes.
Adapter
The Adapter pattern (also known as Wrapper) is a structural design pattern. Its goal is to enable objects with incompatible interfaces to work together.
Implementation:
- Create an adapter class that implements the interface expected by the client;
- Place an existing class with the desired functionality, but incompatible with the interface expected by the client, inside the adapter.
- Implement all the methods of the interface expected by the client in the adapter, delegating all the work to the class placed inside the adapter.
Code example:
/// The interface that the client expects
abstract class Logger {
void log(String message);
}
/// An existing class that has the desired functionality, but an incompatible interface
class ConsoleLogger {
void consoleLog(String message) => print(message);
}
/// Adapter class that adapts [ConsoleLogger] to the [Logger] interface
class ConsoleLoggerAdapter implements Logger {
final ConsoleLogger _consoleLogger;
ConsoleLoggerAdapter(this._consoleLogger);
@override
void log(String message) => _consoleLogger.consoleLog(message);
}
/// The client uses [ConsoleLoggerAdapter] to interact with [ConsoleLogger]
void main() {
final Logger logger = ConsoleLoggerAdapter(ConsoleLogger());
logger.log('Hello, World!'); // Output: 'Hello, World!'
}
Pros
- Allows reusing an existing object by adapting its incompatible interface, separating and hiding the transformation details from the client.
Cons
- Complicates the program code due to the introduction of additional classes.
Decorator
The Decorator pattern (also known as Wrapper) is a structural design pattern. Its goal is to provide the ability to dynamically add new functionality to objects by wrapping them in wrapper classes.
Implementation:
- Create an interface for the component that describes common methods for both the concrete component and its decorators.
- Create a class for the concrete component that contains the core business logic. The concrete component should adhere to the component interface.
- Create a base class for decorators. It should hold a reference to the nested component. The base decorator should also adhere to the same component interface as the concrete component.
- Create classes for concrete decorators that inherit from the base decorator. Each concrete decorator should perform its additional function before or after calling the same function on the wrapped object.
Code example:
/// Component Interface
abstract class TextEditor {
const TextEditor();
abstract final String text;
}
/// Specific component
class SimpleTextEditor implements TextEditor {
const SimpleTextEditor(this.text);
@override
final String text;
}
/// Base class for decorators
abstract class TextEditorDecorator implements TextEditor {
const TextEditorDecorator(this.textEditor);
final TextEditor textEditor;
}
/// Specific decorator
class BoldTextDecorator extends TextEditorDecorator {
const BoldTextDecorator(super.textEditor);
@override
String get text => '<b>${textEditor.text}</b>';
}
/// Specific decorator
class ItalicTextDecorator extends TextEditorDecorator {
const ItalicTextDecorator(super.textEditor);
@override
String get text => '<i>${textEditor.text}</i>';
}
void main() {
TextEditor editor = SimpleTextEditor('Hello world!');
print(editor.text); // Output: 'Hello, World!'
editor = BoldTextDecorator(editor);
print(editor.text); // Output: '<b>Hello, World!</b>'
editor = ItalicTextDecorator(editor);
print(editor.text); // Output: '<i><b>Hello, World!</b></i>'
}
Pros
- Allows dynamically adding one or multiple new responsibilities. Provides greater flexibility compared to inheritance.
Cons
- Involves the creation of multiple small classes.
Command
The Command pattern is a behavioral design pattern. Its goal is to represent actions as objects that encapsulate both the action itself and its parameters. This allows for maintaining a history of actions, queuing them, and supporting undo and redo operations.
The Command pattern is associated with four terms:
- Commands - classes that represent actions as objects.
- Receiver - a class that contains the implementation of actions. Commands delegate their actions to the receiver by calling its methods.
- Invoker - a class that invokes commands. It works with commands only through their common interface, without knowing anything about specific commands. The invoker can keep track of and record executed commands.
- Client - creates instances of concrete commands and associates them with the invoker for execution.
Implementation:
- Create a common interface for commands, defining a method for executing the command.
- Create classes for concrete commands. Each class should have a field that stores the receiver object to which the command will delegate its work. If necessary, the command should receive and store parameters in its constructor required for calling the receiver's methods.
- Add a method to the invoker class for invoking the command. The invoker can either store commands for execution or accept a command in a method for its invocation or have a setter for the command field.
- In the client, create an instance of the receiver, instances of commands, and associate them with the invoker.
Code example:
/// Receiver
class TVControllerReceiver {
TVControllerReceiver();
int _currentChannel = 0;
int _currentVolume = 0;
int get currentChannel => _currentChannel;
int get currentVolume => _currentVolume;
void channelNext() {
_currentChannel++;
print('changed channel to next, now current is $_currentChannel');
}
void channelPrevious() {
_currentChannel--;
print('changed channel to previous, now current is $_currentChannel');
}
void volumeUp() {
_currentVolume++;
print('volume up, now current is $_currentVolume');
}
void volumeDown() {
_currentVolume--;
print('volume down, now current is $_currentVolume');
}
}
/// Command Interface
abstract class Command {
abstract final TVControllerReceiver receiver;
void execute();
}
/// Specific command
class ChannelNextCommand implements Command {
ChannelNextCommand(this.receiver);
@override
final TVControllerReceiver receiver;
@override
void execute() => receiver.channelNext();
}
///Specific command
class ChannelPreviousCommand implements Command {
ChannelPreviousCommand(this.receiver);
@override
final TVControllerReceiver receiver;
@override
void execute() => receiver.channelPrevious();
}
/// Specific command
class VolumeUpCommand implements Command {
VolumeUpCommand(this.receiver);
@override
final TVControllerReceiver receiver;
@override
void execute() => receiver.volumeUp();
}
/// Specific command
class VolumeDownCommand implements Command {
VolumeDownCommand(this.receiver);
@override
final TVControllerReceiver receiver;
@override
void execute() => receiver.volumeDown();
}
/// Invoker
class TVControllerInvoker {
TVControllerInvoker();
Command? _lastCommand;
final List<String> _logs = [];
void executeCommand(Command command) {
command.execute();
_lastCommand = command;
_logs.add('${DateTime.now()} ${command.runtimeType}');
}
void repeatLastCommand() {
final Command? command = _lastCommand;
if (command != null) executeCommand(command);
}
void logHistory() {
for (final String log in _logs) {
print(log);
}
}
}
void main() {
final TVControllerReceiver receiver = TVControllerReceiver();
final TVControllerInvoker invoker = TVControllerInvoker();
invoker.executeCommand(ChannelNextCommand(receiver));
invoker.executeCommand(VolumeUpCommand(receiver));
invoker.repeatLastCommand();
invoker.logHistory();
}
Pros
- Implements the Open/Closed Principle.
- Allows for implementing undo and redo operations, maintaining a history of execution. Enables the composition of complex commands from simple ones.
- Allows for implementing delayed execution of operations by queuing them.
Cons
- Complicates the code due to the need to create multiple additional classes.
- Regenerate response
Visitor
The Visitor pattern is a behavioral design pattern. Its goal is to provide the ability to add new operations to objects of other classes without modifying those classes.
Implementation:
- Create a visitor interface and declare visit() methods in it for each class on which the "visiting" operation will be performed.
- Implement an accept (Visitor visitor) method for the visitor in the interface or base class of the element hierarchy on which the "visiting" operation will be performed. The element hierarchy should only be aware of the base visitor interface, while visitors will be aware of all subclasses of the element hierarchy.
- Implement an accept (Visitor visitor) method for all concrete elements in the hierarchy. Each concrete element should delegate the execution of the accept (Visitor visitor) method to the visitor method where the parameter type matches the current class of the element.
- Create new concrete visitors for each new action on the elements of the hierarchy. Each concrete visitor should implement all methods of the visitor interface, performing the required action.
- Clients create visitor objects and pass them to each element using the accept (Visitor visitor) method.
Code example:
/// The base class of the hierarchy of elements of users of social networks
abstract class SocialNetworkUser {
const SocialNetworkUser();
abstract final String name;
abstract final String link;
void accept(Visitor visitor);
}
/// A specific class of the hierarchy of elements of users of social networks
class VKUser extends SocialNetworkUser {
const VKUser({required this.name, required this.link});
@override
final String name;
@override
final String link;
@override
void accept(Visitor visitor) => visitor.visitVKUser(this);
}
/// A specific class of the hierarchy of elements of users of social networks
class TelegramUser extends SocialNetworkUser {
const TelegramUser({
required this.name,
required this.link,
this.phoneNumber,
});
@override
final String name;
@override
final String link;
final String? phoneNumber;
@override
void accept(Visitor visitor) => visitor.visitTelegramUser(this);
}
/// User interface with declared methods of visiting each class of the hierarchy
abstract class Visitor {
void visitVKUser(VKUser user);
void visitTelegramUser(TelegramUser user);
}
/// A specific interface that performs an action
class LogInfoVisitor implements Visitor {
@override
void visitVKUser(VKUser user) {
print('${user.runtimeType} - ${user.name} - ${user.link}');
}
@override
void visitTelegramUser(TelegramUser user) {
final String phoneNumber = user.phoneNumber ?? 'number hidden';
print('${user.runtimeType} - ${user.name} - ${user.link} - $phoneNumber');
}
}
void main() {
const List<SocialNetworkUser> users = [
VKUser(name: 'Павел Дуров', link: 'vk.com/id1'),
VKUser(name: 'Дмитрий Медведев', link: 'vk.com/dm'),
TelegramUser(name: 'Ivan', link: 't.me/ivan', phoneNumber: '+78005553535'),
TelegramUser(name: 'Anonym', link: 't.me/anon'),
];
final LogInfoVisitor logInfoVisitor = LogInfoVisitor();
for (final SocialNetworkUser user in users) {
user.accept(logInfoVisitor);
}
}
Pros
- Simplifies the addition of new operations to elements.
- Combines related operations in the Visitor class.
- Allows for maintaining state while traversing elements.
Cons
- Complicates the addition of new classes to the hierarchy, as each time the visitor interface and its concrete subclasses need to be updated.
- Regenerate response