Designing with Ports and Adapters
Next, you’ll need to modify the
ShowFormFunction Lambda function to validate the requested extension and return an error message in case of unsupported extensions. The function you wrote in the previous chapter was still relatively easy to read and understand. Putting more logic into that function definitely pushes it beyond the threshold of what could be considered simple.
A big part of making robust code is the ability to understand it easily and modify it with confidence. Both those goals are more attainable with some nice unit tests. However, your current function design is not really making that easy. The function directly talks to S3, requiring IAM privileges to execute, and it depends on several environment variables that need to be configured upfront. Automated tests for this function would be slow and error-prone and it would be difficult to set up all the testing dependencies. This would be a good time to redesign the code and prepare it for future evolution.
At MindMup, we use the Ports and Adapters design pattern (also called hexagonal architecture) for any but the simplest Lambda functions. This makes it easy to cover the code with automated tests at various levels effectively and evolve functions easily over time. The pattern was first described by Alistair Cockburn in 2005. It’s very closely related to the idea of Simplicators, presented by Nat Pryce and Steve Freeman in Growing Object-Oriented Software, and the idea of anti-corruption layers, described by Eric Evans in Domain-Driven Design.
The purpose of this design pattern, straight from its canonical description, is to ‘allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual runtime devices and databases’. Cockburn observed that when business logic gets entangled with external interactions, both become difficult to develop and test. This applies equally to user interface interactions (such as receiving requests from web pages) and to infrastructure interactions (for example saving to a database or acting on queue messages). Lambda code might not have any direct user interactions, but it is always triggered by infrastructure events and often needs to talk to other infrastructure, so this makes Lambda functions prone to the problems that Ports and Adapters addresses.
The design pattern suggests isolating the business logic into a core that has no external interactions but instead provides various interfaces for interactions in the figure given below. These interfaces are the ports, and they are described in the domain model of the business logic. For each type of interaction, you can implement the relevant port interface with an adapter, which translates from the core model into the specific infrastructure API. Test versions of adapters can help you experiment with the business logic in the core easily, in memory, without any specific setup or access to test infrastructure. This means that you could run a huge number of automated tests for the business logic core quickly and reliably. A much smaller number of integration tests focused around adapters can prove the necessary subset of interactions with real infrastructure. Designing with ports and adapters also allows the easy swapping of one infrastructure for another if they serve the same purpose, for example moving external storage from S3 to DynamoDB.