Isolating Dependencies with Ports and Adapters
Problem and Solution Overview
This recipe just states how to execute a solution. Read our Legacy Newsletter blog post: Testing Interactions with Third Party Services to understand the specific problem we are solving and the solution approach. This recipe is one application of the habits taught in our Test Challenging Dependencies Change Series.
This recipe helps you isolate and test a remote dependency. Each pass will isolate one interaction between your system and theirs. You execute it until you have fully isolated the dependency.
- Extract a Port and an Adapter.
- Name one purpose the dependency meets.
- Collect the code that uses that dependency to attain that purpose.
- Refactor to simplify the interface into a Port.
- Create tests for the Adapter.
- Unify and test your Port.
- Supplement the Port definition until each test can use only the Port.
- Simplify other dependencies that attain the same purpose and unify their Ports.
- Unify the tests to drive out variation.
- Isolate and test your integration code.
- Collect and unify the code that uses this Port.
- Unit test the rest.
- Write tests against the collected integration code.
- Create a Simulator that meets the Port tests and use it to test the integration code.
- Run each test at the right times.
- Respond to changes in your dependencies.
Extract a Port and an Adapter
Our goal is to define the Port — how our system wants to use this dependency to achieve one purpose. We could get there by trying to understand the current system or imagining and designing a new one. But we are likely to miss something.
Instead, we will refactor the Port out of the way that our system already uses the dependency. Our refactoring will naturally also create the Adapter — the code that simplifies the actual behavior of the dependency to match the simplicity of our desired Port.
Name One Purpose
A dependency might serve multiple purposes for the system. We want to name just one. It is better to have several simple Ports than one complex one.
For example, a system might connect to a database and use if for 3 purposes:
- Data sharing
- Session management
Pick one dependency and one purpose.
You will usually skip this step after you have executed this recipe once and are extending a Port rather than creating a new one.
Collect the Adapter Code
Find some code that uses the dependency. Categorize it into one of 3 categories:
- Not related to this purpose.
- Defines intent: the code describes what my system wishes to have done.
- Defines implementation: the code manipulates the dependency to achieve the intent.
Collect some code that defines implementation. Use Extract Method refactorings to split it from the intent-defining code. Move the implementation to be on a single new class, or on add it to your existing Adapter.
Name each Adapter method according to the intent it is trying to achieve. Each method reads its parameters to get details and then interacts with the dependency to achieve its intent.
The class that contains all these implementation methods is your Adapter. You will clean it up more later.
Define and Simplify the Port
Extract an interface from your Adapter. This is your initial Port. If you are extending an existing Adapter, then just lift the new methods to the Port.
Now we want to simplify the Port. Several refactorings provide different useful simplifications.
First, use Extract Method and Inline to move data to the right places. For example, your Adapter may currently expose a parameter that appears general, but you only ever set it one of two ways. Extract method for each of those two ways and encapsulate the parameter.
One common step is to use Introduce Parameter Object to reify intent. You can create an object that represents each intention, and then start merging them to cluster related intentions.
Additionally, you can use Replace Method Call or Return with Event to reduce coupling between your Port and the code that uses it. You can use similar refactorings to reduce stack coupling – make code lazy, async, pure, or tell-don’t-ask.
And, of course, there will be lots and lots of Renames.
Continue until the Port is small and intention-revealing.
Create Adapter Tests
Gather the right tests for your Adapter. Don’t worry right now about what kind of tests (Integration, Unit, Platform, …) these are. There are two ways to get a test. Use both.
- Write a new test that invokes your Adapter and its service.
- Find an existing test that invokes your Adapter while also verifying other code. Use Extract Method on the part that invokes your Adapter, duplicate the test, and then execute just that part.
Some of these tests may bypass your Adapter and directly call the service to verify a result. Others will use test-only Adapter methods or interact with the Adapter indirectly through privates and globals. All these testing sins are OK for now.
Unify and Test Your Port
At this point you have a Port, an Adapter, and a set of Tests! They’re all terrible but they exist. Now let’s make them good.
We will make them good by focusing on the Port. We will use the tests as examples for how we want to use the Port, and refactor until the tests are tidy.
Supplement the Port Definition
The first problem is that most tests will go around the Port at some point. The persistence tests will check that the data was actually written to the file or database. The session management tests will set up a couple of fakes in various globals or singletons in order to ensure the context managed correctly. An error response test might call the service and set it up so that it will fault when the code calls it later in the test.
Whatever the sin, Extract it as a Method and move it onto the Adapter, expose it through Port, and then have the test use it via the Port. The Port will now be messy, but every test will go through the Port for everything.
Now make clear what these sins should be doing in the context of your Port and a real system. This usually involves a lot of Renames.
For example, I might have extracted code that calls my
PaymentProcessing service and tells it to to treat the next credit card as invalid. I could Rename this to
GetInvalidFormOfPayment, which might even take a parameter describing how I want it to be invalid (insufficient funds vs stolen vs expired). Yes, it’s even OK to have some method appear to be a simple getter but actually make a change on a remote service! The point is to make the code that uses the Port be stupidly obvious to read.
Unify Ports for all Dependencies of the Same Purpose
Many systems will have several different ways to attain a given purpose. For example, logging in might be done via a local DB auth, via OpenID to any of several providers, or via Active Directory. Some of these may be triggered differently or have fundamentally different workflows, but they are all the same purpose.
Repeat the recipe up to this step with each way your system attains the current purpose. Each will end up with its own Port, Adapter, and Tests.
Next we will unify the Ports and Tests. We merge one pair of Adapters at a time.
First unify the Ports. Refactor one difference at a time until the two Ports are identical. Then make both Adapters implement the same Port and delete the duplicate Port.
Unify Tests for all Adapters
Now we want to unify the Tests. Every Test for the Port must behave identically for any Adapter that implements that Port, but that’s probably not the case for your system yet.
If you are extending an existing port you will extend its shared tests. Otherwise create them now:
- Create an empty test class that defines no tests.
- Have its fixture setup call a virtual method to get the
TestSubjectand store that as a field.
- Then inherit from it once for each Adapter you are merging and override the virtual to return the right Port + Adapter + service.
Now move one test at a time from either of your Adapter test suites into the shared base. First, update any references to the Adapter to use the
TestSubject field. Then fix the failing tests by modifying the Adapter that fails. Repeat until every test has moved into the shared test suite.
The Port and Test merges will probably add a few more methods to the Port, as your system used each service in slightly different ways. Look for ways to simplify the Port. Usually this consists of moving a little code from the Adapters out to the system or from the system into the Adapters.
Then repeat Port and Test unification with the next pair of Adapters until only one remains.
Isolate and Test your Integration Code
Now you have clean Ports and Adapters that could be used by your system in tidy ways. There are even tests that show how to use them well. However, their usage will remain scattered across your system. It remains impossible to test that your system is using your new Ports correctly. So now we want to collect, isolate, and test our integration code — any code that uses our new Port.
Collect the Integration Code
Find all code in your system that uses the Port. Extract Method to split it out, and then use Move Method to gather it all to a new class or set of classes. Eliminate duplicates or near-duplicates, creating small abstractions if needed. Keep this integration code small.
Use your usual approaches to allow the rest of the code to be isolated from the Integration code. Tell-Don’t-Ask, Functional Programming, Reactive Architectures, or Events are all easy ways to allow class separation. Do whatever your system does elsewhere.
Unit Test the Rest
At this point, you have isolated all the rest of your application from this hard-to-test dependency. You have successfully isolated your Domain Code. That should be the majority of your code. Even a common dependency like persistence shouldn’t touch more than 5% of your code.
If Domain Code accounts for less than 95% of your codebase, then you probably didn’t put the Adapter boundaries in the right places. Try to refactor common methods and classes from either side of the Port and see if you can usefully move the Port in or out. Repeat until Domain Code is the vast majority of your system.
Unit test the Domain Code in the usual fashion.
Test the Integration Code
This step is just like when you created tests for an Adapter. Create or extract tests to get a set of tests for your Integration code.
The one difference is that the code already uses the Port. Keep each test as an integration test, using the Port connected to some Adapter and backing service. The only change you should make when extracting a test is that you want to use the same Adapter for every test. That is easy because each Adapter has identical behavior.
You now have all the integration code together, using the dependency in a clean way and only through the Port, and with a set of tests.
Simplify Your Integration Tests
These Tests and Integration code will work on any valid Port implementation. So let’s make a new implementation that has properties we like for testing:
- It operates entirely in-memory, in-process.
- Each instance initializes empty.
- Each instance is independent of any other instance and can run in parallel.
- It does nothing more than what the Port states.
- It is entirely deterministic.
I call this a Simulator.
For example, I may implement an ORM by simply holding objects in a hash table so that I can find them when asked. I could implement a file system as a set of streams held in hash tables, or a notification gateway as an event that the test can listen to or an in-memory call log.
Usually the only hard part is making it deterministic, particularly when it comes to time. The easy solution is to alter your Port to take a
Clock as a dependency. The
Clock is another Port, which can be Adapted to use system time or Adapted to be a simple
StoppedClock which advances only when told to
Whatever my implementation, I create another subclass of my Port tests to verify this Adapter. I TDD it using those tests. This will be fast because my implementation is so simple — though I might spend a while getting it to correctly exhibit exactly the same error conditions as other Adapters.
Now replace the Adapter used in the Integration Tests with this simple one.
Most people think of this Simulator as test code or refer to it as a test double. However, that is not strictly true. The test cannot use this Adapter in any way that is different from how it could use any other Adapter. This is just a very fast, in-memory form of the Adapter that is limited in non-functional ways (eg, persistence only lasts until the object is destroyed).
In fact, these Simulators often migrate into the product to improve performance or add features. Need to speed up persistence? Connect your Hash Table ORM as a cache. It already behaves identically with your other persistence mechanisms, so you don’t need to change the Integration code or Domain code at all. Similarly, you can enable telemetry by connecting your event-dispatch notification system and binding it to your telemetry log library method. You just implemented system-wide logging with 2-3 lines of code.
Run Each Test Right
We are used to categorizing tests by what they are, and then running them accordingly. This test is a unit test and developers will run it in their IDEs and CI systems. That test is an integration test and the CI system will run it at a specific stage after it has set up various systems. Another test is actually telemetry and we will run it in production.
None of these tests are so easily categorized.
The Port tests are defined once in a base class, but each run differently.
- All those instantiated with Simulators should be part of your unit test suite and run on every code change in the IDE.
- Those instantiated with Adapters that use first-party services should be part of your CI integration test suite and run on every commit.
- Those instantiated with Adapters to third-party services should be part of your platform test suite, and run every hour against their production services.
Likewise, the tests for the Integration Code are integration tests…with all the properties of a unit test. They do execute multiple units, but we’ve chosen the Adapter to give the resulting test the traits we want. So name them integration tests and run them as part of the unit test suite.
The resulting tests that will catch integration errors without ever executing your full system against theirs. You get the benefit of full-system integration testing without the fragility and cost that comes from testing all the complexity at once.
Responding to a Service Change
Your dependencies will periodically change their behavior. Some of those changes will impact you and some won’t. Because your Port and its Tests define how you depend on the system, those tests will start to fail if, and only if, their change would impact your system. Your overlapping tests will guide you to make your changes in a way that continues to work for the whole system. Here’s how.
- They change their system. Your tests run against their system within an hour. If their change does not affect your product, the tests will continue to pass. Otherwise continue to the next step.
- The failing platform test tells you the behavior you expect and the new behavior. Choose whether you want the new behavior or the old. If you want the old behavior, update the Adapter until the test passes. If you want the new behavior, change the test and go on to the next step.
- The test will now pass for this one Adapter but likely fail for all the other Adapters that implement this Port. Use the failing tests to TDD a change in each Adapter until the test passes again. This will include your Simulator. But changing the Port definition will require your Integration code to change. Fortunately, that code is integration tested with a real Adapter (one of your Simple ones), so the correct Integration tests will now be failing. Go on to the next step.
- Use the Integration tests to TDD changes to your Integration code.
Check in each time you get one test to pass. Temporarily mark any other failing tests to be ignored (or known failing). Then fix them, one commit at a time.
And that’s it. At this point you have refactored your legacy system to fully isolate one non-domain purpose and the dependencies that deliver that purpose. You have tested each piece of code in a way that you can detect integration problems, without having tests that fail on every code change.