An overview of the Adapter design pattern and its implementation in Dart and Flutter
In the last article, I analysed the first design pattern in the series — Singleton, provided some general thoughts about its structure, applicability, pros and cons, implemented it in several different ways. This time, I would like to analyse and implement a design pattern that belongs to the category of structural design patterns — Adapter.
To see all the design patterns in action, check the Flutter Design Patterns application.
What is the Adapter design pattern?​
Adapter is a structural design pattern, also known as wrapper. It is one of the most common and useful design patterns available to us as software developers. The intention of this design pattern is described in the GoF book:
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
Let’s say you want to use some kind of third-party library that has the functionality you need. Also, you have a class that needs to utilize a particular interface, but interfaces between your code and the library are incompatible. In this case, you can use the library’s code by creating an adapter class that “sits” between your code and the code you want to use from that library. Moreover, the pattern is useful when you want to ensure that some particular piece of code in your software could be reused by wrapping it with an adapter and exposing an interface to it. This lets the future implementations in your code depend on Adapter interfaces, rather than concrete classes directly. Sounds good already? Well, let’s move to the analysis to understand how this pattern works and how it could be implemented.
Analysis​
The class diagram below shows the general structure of the Adapter design pattern:
To be more specific, there are two general implementations of Adapter with a different structure — Object and Class adapters (we will talk about the differences between these two next). Despite different structure between object and class adapters, they share the same idea of participants (elements or classes used to implement the pattern):
- Target or ITarget defines the domain-specific interface that Client uses;
- Client collaborates with objects conforming to the Target interface;
- Adaptee defines an existing interface that needs adapting (e.g. third-party library);
- Adapter adapts the interface of Adaptee to the Target interface. This is the main node in the pattern, which connects the Client code with the code which wants to be used (Adaptee).
Object adapter vs Class adapter​
To begin with, both object and class adapters are valid implementations of the Adapter design pattern, but their structure is different as well as their advantages and trade-offs. Object adapter relies on object composition to implement a Target interface in terms of (by delegating to) an Adaptee object. That is, the adapter implements the Target operation by calling a concrete operation on the property or instance of Adaptee class. The class adapter uses inheritance to implement a Target interface in terms of (by inheriting from) an Adaptee class. This way, the concrete operation from the Adaptee class could be called directly from the implementation of Target operation. The question is, which one to use?
My personal choice between these two possible implementations is the object adapter. Here are the reasons why:
- To implement the Adapter design pattern using the class adapter method, the programming language of your choice must support multiple inheritance. In Dart, multiple inheritance is not supported.
- One of the advantages of a class adapter is that you can easily override the behaviour of the adaptee class — you extend the adaptee class, right? However, the object adapter method is more flexible since it commits to an Adaptee class at run-time (client and adaptee code are loosely coupled). It means that you can create a single adapter that could use multiple different adaptees as long as their interfaces (types) matches the one adapter requires.
- I prefer composition over inheritance. What I bear in my mind, if you try to reuse the code by inhering it from a class, you make the subclass dependent on the parent class. When using composition, you decouple your code by providing interfaces which implementations can be easily replaced (in this case, the implementation of Adaptee could be replaced inside the Adapter class at run-time). This is only a glimpse of the Liskov substitution principle (the letter L in SOLID principles), which is pretty difficult to understand and apply, but it is worth the effort.
Applicability​
The adapter design pattern could (and should!) be used when the interface of the third-party library (or any other code you want to use) does not match the one you are currently using in your application. This rule could also be applied when calling external data sources, APIs and you want to wrap and separate the data conversion code from the primary business logic in your program. The pattern is useful when you want to use multiple implementations (adaptees) that have similar functionality but use different APIs. In this case, all the “hard work” (implementation) could be done in the Adapter classes, whereas the domain-layer code will use the same interface of the adapters. Also, this code abstraction makes the unit testing of the domain-layer code a little bit easier.
Implementation​
Let’s say, in the Flutter application we want to collect our contacts from two different sources. Unfortunately, these two sources provide the contacts in two different formats — JSON and XML. Also, we want to create a Flutter widget that represents this information in a list. However, to make the widget universal, it cannot be tied to a specific data format (JSON or XML), so it accepts these contacts as a list of Contact objects and does not know anything about how to parse JSON or XML strings to the required data structure. So we have two incompatible interfaces — our UI component (widget), which expects a list of Contact objects, and two APIs, which return contacts’ information in two different formats. As you could have guessed, we will use the Adapter design pattern to solve this problem.
Class diagram​
The class diagram below shows the implementation of the Adapter design pattern using the object adapter method:
First of all, there are two APIs: JsonContactsApi
and XmlContactsApi
. These two APIs have different methods to return contacts information in two different formats — JSON and XML. Hence, two different adapters should be created to convert the specific contacts’ representation to the required format which is needed in the ContactsSection
component (widget) — list of Contact
objects. To unify the contract (interface) of adapters, IContactsAdapter
abstract interface class is created which requires implementing the getContacts()
method in all the implementations of this interface. JsonContactsAdapter
implements the IContactsAdapter
, uses the JsonContactsApi
to retrieve contacts information as a JSON string, then parses it to a list of Contact
objects and returns it via getContacts()
method. Accordingly, XmlContactsAdapter
is implemented in the same manner, but it receives the data from XmlContactsApi
in XML format.
Contact​
Contact is a plain Dart class (as some people from Java background would call it — POJO) to store the contact’s information.
class Contact {
final String fullName;
final String email;
final bool favourite;
const Contact({
required this.fullName,
required this.email,
required this.favourite,
});
}
This class is used inside the UI widget ContactsSection
and both of the adapters to return the parsed data from APIs in an acceptable format for the UI.
JsonContactsApi​
A fake API which returns contacts’ information as a JSON string.
class JsonContactsApi {
static const _contactsJson = '''
{
"contacts": [
{
"fullName": "John Doe (JSON)",
"email": "johndoe@json.com",
"favourite": true
},
{
"fullName": "Emma Doe (JSON)",
"email": "emmadoe@json.com",
"favourite": false
},
{
"fullName": "Michael Roe (JSON)",
"email": "michaelroe@json.com",
"favourite": false
}
]
}
''';
const JsonContactsApi();
String getContactsJson() => _contactsJson;
}
XmlContactsApi​
A fake API which returns contacts’ information as an XML string.
class XmlContactsApi {
static const _contactsXml = '''
<?xml version="1.0"?>
<contacts>
<contact>
<fullname>John Doe (XML)</fullname>
<email>johndoe@xml.com</email>
<favourite>false</favourite>
</contact>
<contact>
<fullname>Emma Doe (XML)</fullname>
<email>emmadoe@xml.com</email>
<favourite>true</favourite>
</contact>
<contact>
<fullname>Michael Roe (XML)</fullname>
<email>michaelroe@xml.com</email>
<favourite>true</favourite>
</contact>
</contacts>
''';
const XmlContactsApi();
String getContactsXml() => _contactsXml;
}
IContactsAdapter​
A contract (interface) that unifies adapters and requires them to implement the method getContacts()
.
abstract interface class IContactsAdapter {
List<Contact> getContacts();
}
JsonContactsAdapter​
An adapter, which implements the getContacts()
method. Inside the method, contacts’ information is retrieved from JsonContactsApi
as a JSON string and parsed to the required return type (a list of Contact
objects).
class JsonContactsAdapter implements IContactsAdapter {
const JsonContactsAdapter({
this.api = const JsonContactsApi(),
});
final JsonContactsApi api;
List<Contact> getContacts() {
final contactsJson = api.getContactsJson();
final contactsList = _parseContactsJson(contactsJson);
return contactsList;
}
List<Contact> _parseContactsJson(String contactsJson) {
final contactsMap = json.decode(contactsJson) as Map<String, dynamic>;
final contactsJsonList = contactsMap['contacts'] as List;
final contactsList = contactsJsonList.map((json) {
final contactJson = json as Map<String, dynamic>;
return Contact(
fullName: contactJson['fullName'] as String,
email: contactJson['email'] as String,
favourite: contactJson['favourite'] as bool,
);
}).toList();
return contactsList;
}
}
XmlContactsAdapter​
An adapter, which implements the getContacts()
method. Inside the method, contacts’ information is retrieved from XmlContactsApi
as an XML string and parsed to the required return type (a list of Contact
objects).
class XmlContactsAdapter implements IContactsAdapter {
const XmlContactsAdapter({
this.api = const XmlContactsApi(),
});
final XmlContactsApi api;
List<Contact> getContacts() {
final contactsXml = api.getContactsXml();
final contactsList = _parseContactsXml(contactsXml);
return contactsList;
}
List<Contact> _parseContactsXml(String contactsXml) {
final xmlDocument = XmlDocument.parse(contactsXml);
final contactsList = <Contact>[];
for (final xmlElement in xmlDocument.findAllElements('contact')) {
final fullName = xmlElement.findElements('fullname').single.innerText;
final email = xmlElement.findElements('email').single.innerText;
final favouriteString =
xmlElement.findElements('favourite').single.innerText;
final favourite = favouriteString.toLowerCase() == 'true';
contactsList.add(
Contact(
fullName: fullName,
email: email,
favourite: favourite,
),
);
}
return contactsList;
}
}
Example​
First of all, a markdown file is prepared and provided as a pattern’s description:
The example itself uses the ContactsSection
component which requires a specific adapter of type IContactsAdapter
to be injected via constructor.
class AdapterExample extends StatelessWidget {
const AdapterExample();
Widget build(BuildContext context) {
return const ScrollConfiguration(
behavior: ScrollBehavior(),
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: LayoutConstants.paddingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ContactsSection(
adapter: JsonContactsAdapter(),
headerText: 'Contacts from JSON API:',
),
SizedBox(height: LayoutConstants.spaceL),
ContactsSection(
adapter: XmlContactsAdapter(),
headerText: 'Contacts from XML API:',
),
],
),
),
);
}
}
ContactsSection
widget uses the injected adapter of type IContactsAdapter
. The widget only cares about the adapter’s type (interface), but not its specific implementation. Hence, we can provide different adapters of type IContactsAdapter
which load the contacts’ information from different data sources without making any changes to the UI.
class ContactsSection extends StatefulWidget {
final IContactsAdapter adapter;
final String headerText;
const ContactsSection({
required this.adapter,
required this.headerText,
});
_ContactsSectionState createState() => _ContactsSectionState();
}
class _ContactsSectionState extends State<ContactsSection> {
final List<Contact> contacts = [];
void _getContacts() {
setState(() {
contacts.addAll(widget.adapter.getContacts());
});
}
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(widget.headerText),
const SizedBox(height: LayoutConstants.spaceM),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: _ContactsSectionContent(
contacts: contacts,
onPressed: _getContacts,
),
),
],
);
}
}
class _ContactsSectionContent extends StatelessWidget {
final List<Contact> contacts;
final VoidCallback onPressed;
const _ContactsSectionContent({
required this.contacts,
required this.onPressed,
});
Widget build(BuildContext context) {
return contacts.isEmpty
? PlatformButton(
materialColor: Colors.black,
materialTextColor: Colors.white,
onPressed: onPressed,
text: 'Get contacts',
)
: Column(
children: <Widget>[
for (var contact in contacts)
ContactCard(
contact: contact,
)
],
);
}
}
The final result of the Adapter’s implementation looks like this:
All of the code changes for the Adapter design pattern and its example implementation could be found here.
To see the pattern in action, check the interactive Adapter example.