Sitemap

When Good Code Doesn’t Fit: You Might Need an Adapter

15 min readApr 24, 2025

In the previous article — Why Adding Features Shouldn’t Break Your Code: Meet the Decorator Pattern — we talked about wrapping objects to extend their behavior without changing their core. That’s great when your object already fits the system and just needs extra capabilities.

But what if it doesn’t fit at all?

Adapter Design Pattern

Let’s say your app uses a new payment gateway that returns transactions as a PaymentResult object with fields like amount, currency, and statusCode. Meanwhile, your internal analytics module expects AnalyticsEvents with a different shape — maybe a string-based status, a flat payload, and a different naming convention. Both systems are solid, production-grade, and built with clear interfaces — they just speak different languages.

You don’t want to modify either side. You just want them to connect. That’s when the Adapter pattern becomes useful — not to enhance, but to translate. In this article, we’ll explore how Adapter lets clean, well-structured systems work together even when their interfaces don’t align — and how to implement it properly in Swift without breaking abstraction or duplicating logic.

What Is the Adapter Pattern?

The Adapter is a structural design pattern whose purpose is to allow objects with incompatible interfaces to work together. It achieves this by wrapping one object (the adaptee) inside another object (the adapter) that presents a compatible interface to the client. In other words, the adapter converts the interface of the adaptee into another interface that the client expects. Because it acts as an intermediary, the Adapter pattern is sometimes also known as the Wrapper pattern (a term it incidentally shares with the Decorator pattern, even though their intents differ).

ObjectAdapter — Adapter pattern — Wikipedia

An obvious real-world analogy of an adapter: a travel plug adapter that allows a device with one plug type to connect to a different socket type. The adapter doesn’t change the device or the outlet — it simply makes them compatible.

Think of an adapter like a translator between two people who speak different languages, or a power plug adapter when traveling. For example, if you have an electronic device with a three-prong plug and the wall outlet only accepts two-prong plugs, you use a plug adapter. The adapter has a three-prong socket on one side and a two-prong plug on the other, allowing your device to connect to the outlet. The device and outlet remain unchanged, but the adapter makes them compatible by converting the interface (plug shape) of one into a form that the other can accept. In software, the Adapter pattern works analogously: it takes the interface of an existing class or component and translates it into an interface that the rest of the code can work with.

The Adapter pattern converts one interface to another so that classes that couldn’t otherwise cooperate due to interface mismatches can now interact. It’s often used to integrate third-party or legacy code into new systems without modifying the original source. As a result, you can reuse existing, proven code even if it doesn’t match the interfaces your application uses.

Adapter Types: Object Adapter vs. Class Adapter

There are two flavors of the Adapter pattern: object adapters and class adapters. The difference lies in whether the adapter achieves its goal through composition or inheritance:

  • Object Adapter (Composition) — This is the more common approach. The adapter holds an instance of the adaptee and delegates calls to it. In other words, the adapter contains the adaptee. This relies on object composition: the adapter translates the target interface to the adaptee by calling methods on the contained object. Because it’s using composition, an object adapter can wrap any class that implements the needed functionality, even instances of classes that the adapter doesn’t inherit from.
  • Class Adapter (Inheritance) — This variant uses inheritance (and sometimes multiple inheritance) to adapt one interface to another. A class adapter subclasses (or inherits from) the adaptee and at the same time implements the target interface. This means the adapter is a kind of specialized subclass that represents the adaptee in the new role. Class adapters are less common because they require the language to support multiple inheritance or at least inheriting from a class and implementing an interface simultaneously. They also tightly couple the adapter to the adaptee’s implementation via inheritance.

In simple terms, a class adapter uses subclassing, while an object adapter uses delegation via composition. The choice has practical implications. Object adapters are generally preferred — not only does this follow the “favor composition over inheritance” principle, it also means the adapter isn’t limited by a single specific adaptee class. An object adapter can work with an adaptee that is an instance of a concrete class or even any class implementing a certain interface (since it just holds a reference to an object with the necessary behavior). In contrast, a class adapter is tied to extending one specific class.

When to Use Adapter Instead of Inheritance

You might wonder: “If I need to make a class work with a new interface, why not just subclass it and override some methods?” Subclassing (inheritance) can indeed let you provide a different implementation for some behavior, but it cannot change the original interface of the class. In other words, inheritance can add or override methods, but if the client expects a totally different set of methods (a different interface), a subclass still won’t have those methods. An Adapter is specifically useful when you cannot modify the existing class’s code or interface (maybe it’s provided by a library or it’s already widely used) but you need it to behave as if it implemented a new interface.

Common scenarios where Adapter is more appropriate than direct inheritance include:

Integrating Third-Party or Legacy Code

Suppose you have a third-party library class or an old legacy class that provides the functionality you need, but its API doesn’t match what your code expects. Perhaps your code is built around a certain protocol or interface. You can’t change the third-party class (and subclassing it might not help if the interface mismatch is significant), so you write an adapter that implements your expected interface and internally uses the third-party class. This “middle-layer class” acts as a translator between your code (the client) and the service class. For example, if a library returns data in an old format and your app uses a new data model, an adapter can convert library data objects into your app’s model objects.

Working with Incompatible Subsystems

Sometimes you have two parts of a system that were developed independently or at different times, with different assumptions about interfaces. Adapter can be used to make them cooperate without refactoring one or the other to conform. For instance, one module might call a function doWork() to perform an action, while another module provides the same functionality via a method performTask(). An adapter can implement doWork() by internally calling performTask() on an instance of the other module.

Replacing Inheritance for Flexibility

Even if inheritance could be used, adapters can be more flexible. Inheritance ties you to a specific parent class. Adapter (with composition) lets you adapt multiple targets over time. You could write several adapter classes for different adaptee classes that all present the same target interface to the client, and the client code wouldn’t have to change when you switch adapters. This is aligned with the Open/Closed Principle: your system is open to new integrations (just write a new adapter) but closed to modifications in existing code.

In summary, use an Adapter when you have an existing class that almost does what you need but doesn’t conform to the interface you require, and you either cannot or should not change the existing class. Instead of altering it (which might break existing code or violate open/closed principle), you write a small adapter that reuses the original class’s functionality and presents a new interface to the world.

Benefits of the Adapter Pattern

The Adapter pattern offers several benefits, especially in large, evolving codebases:

Enable Reuse of Existing Functionality

Perhaps the biggest advantage is that you can reuse code without modifying it. If you have a stable, well-tested component, you don’t want to fork or rewrite it just to fit your code. Adapter lets you keep that code as-is and write a thin layer to make it work in your system. This saves time and reduces risk of introducing bugs in tried-and-true code.

Compatibility Without Modifying Source

Adapter provides a way to integrate components that weren’t designed to work together. You can introduce an adapter class to match the interface of one to the interface of another. The client code can remain unchanged and unaware that an adapter is even in use — it just sees an object that meets its expectations. This is a practical application of the Open/Closed Principle (OCP): your system can be extended with new adapters to accommodate new interfaces without changing existing client code.

Adheres to Single Responsibility Principle

By confining the interface conversion logic to the adapter, you keep the conversion or translation code separate from your business logic. The adapter has one job: translate calls between two interfaces. This means your primary classes can focus on their main responsibilities. The adapter itself can be seen as having the sole responsibility of bridging the gap between interfaces.

Decoupling and Flexibility

Clients are decoupled from the concrete implementations of services. They rely on the target interface and don’t need to know about the adaptee. If at some point you replace the underlying service with a different implementation, you can just swap out or write a new adapter as needed. The rest of the code doesn’t have to change, as long as the new adapter still conforms to the expected interface. This makes your code more modular and easier to maintain or upgrade in the future.

Consistent Interfaces

Adapters can help enforce a uniform interface across your system. For example, if you want all logging in your app to use a certain Logger interface, but you have multiple logging frameworks in use (each with different APIs), you can create adapters for each so that they all present the same Logger interface to your code. The benefit is that the rest of your codebase interacts with a consistent set of methods, improving clarity and reducing the cognitive load on developers.

In essence, Adapter lets you leverage existing code and integrate it cleanly into new contexts. You avoid the dreaded situation of “I need to rewrite this from scratch because it doesn’t fit” — instead, you adapt it.

Drawbacks and How to Address Them

Like any design pattern, Adapter has its costs. There are some potential drawbacks, though many can be managed with careful use:

Boilerplate Code

Introducing an adapter means writing additional code — typically, a class or struct that implements the target interface and forwards calls to the adaptee. If the interfaces are large, the adapter might have to implement many methods that mostly delegate to the adaptee, which can be tedious. This extra layer increases the amount of code in the system.

How to address this

Generate or templatize where possible. In Swift, you might use protocols with default implementations to reduce repetitive code. If you find yourself writing many adapters that do similar things, consider if you can refactor or simplify the interfaces to reduce the need for adapters.

However, remember that this boilerplate is often the trade-off for keeping two systems separate — it might be preferable to writing a bunch of glue code scattered throughout the codebase. At least with an adapter, all the conversion logic is in one place.

Increased Complexity & Indirection

An adapter adds another layer of indirection. When reading the code, a developer needs to know that a certain object is actually an adapter forwarding to something else. If adapters are overused or stacked, the flow of a call can become harder to follow (you jump through multiple translator objects). Additionally, adding new classes (adapters, target interfaces) increases the overall complexity of the design.

How to address this

Use adapters judiciously. Don’t adapt just for the sake of it — if a small tweak in design could avoid an adapter, consider that alternative. Clearly document and name adapters so their purpose is evident. Keep the adapter’s implementation straightforward — ideally just mapping one call to another — to minimize cognitive overhead.

Fragile Chains of Adapters

Overuse of adapters can lead to a scenario where you have adapters wrapping adapters in sequence (perhaps as requirements change over time). Each adapter assumes the thing it’s adapting behaves a certain way. If one link in the chain changes or breaks, the whole chain can fail in unpredictable ways.

How to address this

Try to consolidate adapters. If you notice multiple layers of adaptation, consider writing a direct adapter between the two end points to remove intermediate layers. Also, comprehensive testing of adapters is important — ensure that the adapter’s outputs truly match what the next component expects.

Performance Overhead

Generally the performance cost of an adapter is small — just an extra method call or data conversion. However, if the adaptee’s interface is very granular or the adapter does heavy data transformation, this overhead could become noticeable. Also, extra objects (the adapter instances) mean more memory use, albeit typically small.

How to address this

Measure if performance is critical. If an adapter is in a performance-sensitive path, ensure the adapter’s code is efficient (e.g., avoid redundant conversions). In Swift, small adapters might be optimized away by the compiler, especially if using protocols with static dispatch, but that’s an implementation detail.

Not Solving Fundamental Design Mismatch

An adapter can only bridge interfaces that are logically compatible to begin with. If two components are fundamentally incompatible in functionality or semantics, an adapter may not be able to reconcile them. This is more of a limitation than a drawback, but it’s worth noting so you don’t force the pattern where it doesn’t fit.

How to address this

Recognize when a mismatch is too fundamental. In such cases, you might need a different integration strategy or deeper refactoring. Use adapters for what they’re good at: interface conversion, not heavy lifting of incompatible logic.

In summary, the Adapter pattern introduces a bit of extra code and indirection, which you should manage through sensible design and documentation. The trade-offs are usually well worth it when you need the flexibility and compatibility that adapters provide. Just be mindful to keep the adapter layer as simple and transparent as possible.

Limitations and Caveats

While we touched on some limitations above, here we’ll explicitly call out a few caveats to keep in mind when using the Adapter pattern:

Adapters Don’t Add New Functionality

An adapter isn’t meant to be a feature-rich decorator or enhancer; it’s primarily a translator. This means the adapter can’t make an incompatible class do something it couldn’t do before. It can only make it possible to call the existing functionality through a new interface.

If the adaptee lacks some capability entirely, the adapter can’t magically create it. If you need to actually extend behavior, consider patterns like Decorator or a full wrapper/facade that implements richer logic.

Maintenance Overhead

If the interface of either side changes — the Target or the Adaptee — you need to update the adapter. Adapters need to be maintained as the system evolves. For example, if a new method is added to the target interface that the client starts calling, the adapter must implement it. If the adaptee gets a new version with a different API, the adapter might break or need adjustment. In a sense, the adapter is tightly coupled to both sides — it depends on the target interface by definition, and typically on the adaptee’s interface/behavior internally.

This is expected, but it means when either side changes, adaptors often need revisiting. A robust test suite for adapters can catch issues if an underlying class changes unexpectedly.

Not Always the Simplest Solution

Sometimes the need for an adapter is a signal that perhaps the design could be refactored. If you own both sides of the code (client and service), it might be easier to tweak one or both to use a common interface rather than introducing an adapter. Adapter makes the most sense when one or both sides are out of your control or represent a stable contract you don’t want to alter.

If you find yourself writing an adapter within your own module for two classes you also wrote, ask if there’s a simpler way. It might still be valid — perhaps those classes are in different layers that shouldn’t know about each other’s details — but it’s worth a second thought.

In short, the Adapter pattern is extremely useful for specific problems, but it’s not a cure-all. It won’t fix deeper incompatibilities in logic, and it does add an extra piece to manage. Use it when the benefits outweigh the downsides, and always consider the context — especially in terms of long-term maintenance.

Swift Code Example

Let’s walk through a concrete example in Swift to illustrate the Adapter pattern in action. Consider a scenario where you have a legacy analytics system and a new application codebase that expects a different analytics interface.

  • The adaptee (existing component) is a legacy analytics service class that logs events in a particular way.
  • The target (desired interface) is a protocol that our application uses for analytics (perhaps a modern, protocol-based approach).
  • The adapter will implement the new protocol but use the legacy service under the hood, translating calls as needed.

Scenario: Your app tracks user events through an AnalyticsTracker protocol. However, the company’s old analytics system uses a class LegacyAnalyticsService with its own API. The legacy service, for example, only accepts events as a JSON string. We’ll create an adapter so that the rest of the app can use AnalyticsTracker seamlessly, and behind the scenes the events go through LegacyAnalyticsService.

First, define the legacy analytics service and the new analytics protocol (target interface):

import Foundation

/// The legacy analytics service (the Adaptee) which we cannot change easily.
class LegacyAnalyticsService {
func logEvent(name: String, data: String) {
// Imagine this sends the event name and data string to an old analytics system.
print("LegacyAnalyticsService: Logging '\(name)' with data: \(data)")
}
}

/// A modern analytics tracking interface (the Target) that the rest of the app uses.
protocol AnalyticsTracker {
func track(event name: String, properties: [String: Any])
}

In this setup, the legacy service requires event data to be pre-formatted as a single String (maybe JSON or some format). The new code, however, would like to just pass a dictionary of properties for an event. Now, we create an adapter that implements AnalyticsTracker but uses LegacyAnalyticsService internally:

/// The Adapter class that bridges AnalyticsTracker interface to LegacyAnalyticsService.
class LegacyAnalyticsAdapter: AnalyticsTracker {
private let legacyService: LegacyAnalyticsService

init(service: LegacyAnalyticsService) {
self.legacyService = service
}

// Implement the required method of AnalyticsTracker
func track(event name: String, properties: [String: Any]) {
// Convert the properties dictionary to a JSON string (since legacy expects a string).
// In a real scenario, proper JSON encoding and error handling would be needed.
let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: [])
let jsonString = jsonData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"

// Delegate the call to the adaptee with converted data.
legacyService.logEvent(name: name, data: jsonString)
}
}

A few things to note in this adapter implementation:

  • It stores an instance of LegacyAnalyticsService (through composition).
  • It implements the track(event:properties:) method expected by AnalyticsTracker.
  • Inside track, it adapts the call: it takes the properties [String: Any] dictionary and converts it into a JSON string (because LegacyAnalyticsService requires a String as data). Then it calls legacyService.logEvent(name: data:) with the converted data.
  • The rest of the application doesn’t need to know about this conversion. As far as the app is concerned, it’s calling track(event:properties:) on an AnalyticsTracker.

Now, let’s see how we would use this in practice:

// Somewhere in the application setup:
let legacyService = LegacyAnalyticsService()
// Wrap the legacy service with the adapter to present a unified AnalyticsTracker interface.
let analyticsTracker: AnalyticsTracker = LegacyAnalyticsAdapter(service: legacyService)

// Now use analyticsTracker in the app as if it were a modern analytics tool.
func onUserSignedUp(username: String) {
// We want to track a signup event with some properties.
analyticsTracker.track(event: "UserSignup", properties: [
"username": username,
"timestamp": Date().timeIntervalSince1970,
"referral": "LandingPage"
])
}

// Simulate a user sign-up event:
onUserSignedUp(username: "Maxim123")

If you run this code, the output might look like:

LegacyAnalyticsService: Logging 'UserSignup' with data: 
{"username":"Maxim123","timestamp":1745058575.2475681,"referral":"LandingPage"}

The application code called analyticsTracker.track(...) without caring about implementation details. The adapter handled converting the event data to JSON and delegated to the legacy service’s logEvent method. This way, the app stays decoupled from the old analytics API. If in the future you replace LegacyAnalyticsService with a new system, you could write a new adapter (or if the new system already matches AnalyticsTracker, use it directly) without changing the app logic that triggers events.

This example reflects a real-world-ish problem: integrating a legacy system into a new interface. Adapters are frequently used for such tasks — aligning old and new code. Swift’s JSONSerialization in this case is just to show format conversion; in many adapters, you’ll have to convert between data formats or coordinate units, which is exactly what we did by mapping a [String: Any] to JSON text.

Conclusion

The Adapter design pattern is a vital technique when you need to reconcile differences between software components. It allows you to introduce new code (adapters) that make incompatible interfaces compatible, instead of forcing changes upon existing, stable code. We saw that the Adapter keeps your system compliant with the Open/Closed Principle: you can extend the system (by adding a new adapter) without modifying the clients or the adaptee classes. Use it when you have to integrate external libraries, legacy code, or any modules that don’t quite fit together out of the box.

In contrast to the Decorator pattern, which is about augmenting behavior, Adapter is about achieving compatibility. Both patterns involve wrapping an object, and in both cases this helps with modularity and maintaining clean abstractions. Together, they enrich your design toolbox: Decorator lets you add features without breaking code, and Adapter lets you plug in new pieces without rewriting everything.

If this article helped you see the Adapter pattern in a new light — let me know. A few claps, a comment, or sharing it with someone who’s been fighting mismatched APIs all week goes a long way. And if you’re curious how this pattern fits into broader architectural decisions, hit follow — more articles are on the way.

Keep your interfaces clean, your adapters small, and your coffee strong. See you in the next pattern! ☕️

--

--

Maxim Gorin
Maxim Gorin

Written by Maxim Gorin

Team lead in mobile development with a passion for Fintech and Flutter. Sharing insights and stories from the tech and dev world on this blog.

Responses (1)