Ports and Adapters is an architectural pattern that separates the application's core logic (Ports) from external dependencies (Adapters).
This example shows how to implement a simple application using this pattern and the gem solid-adapters.
Let's start seeing the code structure:
├── Rakefile
├── config.rb
├── db
├── app
│ └── models
│ └── user
│ ├── record
│ │ └── repository.rb
│ └── record.rb
├── lib
│ └── user
│ ├── creation.rb
│ ├── data.rb
│ └── repository.rb
└── test
└── user_test
└── repository.rb
The files and directories are organized as follows:
Rakefileruns the application.config.rbfile contains the configuration of the application.dbdirectory contains the database. It is not part of the application, but it is used by the application.appdirectory contains "Rails" components.libdirectory contains the core business logic.testdirectory contains the tests.
The application is a simple "user management system". It unique core functionality is to create users.
Now we understand the code structure, let's see the how the pattern is implemented.
In this application, there is only one business process: User::Creation (see lib/user/creation.rb), which relies on the User::Repository (see lib/user/repository.rb) to persist the user.
The User::Repository is an example of port, because it is an interface/contract that defines how the core business logic will persist user records.
module User::Repository
include Solid::Adapters::Interface
module Methods
def create(name:, email:)
name => String
email => String
super.tap { _1 => ::User::Data[id: Integer, name: String, email: String] }
end
end
endThe User::Repository is implemented by two adapters:
-
User::Record::Repository(seeapp/models/user/record/repository.rb) is an adapter that persists user records in the database (through theUser::Record, that is anActiveRecordmodel). -
UserTest::Repository(seetest/user_test/repository.rb) is an adapter that persists user records in memory (through theUserTest::Data, that is a simple in-memory data structure).
The benefit of doing this is that the core business logic is decoupled from the external dependencies, which makes it easier to test and promote changes in the code.
For example, if we need to change the persistence layer (start to send the data to a REST API or a Redis DB), we just need to implement a new adapter and make the business processes (User::Creation) use it.
Use this pattern when there is a real need to decouple the core business logic from external dependencies.
You can start with a simple implementation (without Ports and Adapters) and refactor it to use this pattern when the need arises.
You can eliminate the overhead by disabling the Solid::Adapters::Interface, which is enabled by default.
When it is disabled, the Solid::Adapters::Interface won't prepend the interface methods module to the adapter, which means that the adapter won't be checked against the interface.
To disable it, set the configuration to false:
Solid::Adapters.configuration do |config|
config.interface_enabled = false
endIn the same directory as this README, run:
rake # or rake SOLID_ADAPTERS_ENABLED=enabled
# or
rake SOLID_ADAPTERS_ENABLED=falseProxy enabled
rake # or rake SOLID_ADAPTERS_ENABLED=enabled
# Output sample:
#
# -- Valid input --
#
# Created user: #<struct User::Data id=1, name="Jane", email="jane@foo.com">
# Created user: #<struct User::Data id=1, name="John", email="john@bar.com">
#
# -- Invalid input --
#
# rake aborted!
# NoMatchingPatternError: nil: String === nil does not return true (NoMatchingPatternError)
# /.../lib/user/repository.rb:9:in `create'
# /.../lib/user/creation.rb:12:in `call'
# /.../Rakefile:36:in `block in <top (required)>'Proxy disabled
rake SOLID_ADAPTERS_ENABLED=false
# Output sample:
#
# -- Valid input --
#
# Created user: #<struct User::Data id=1, name="Jane", email="jane@foo.com">
# Created user: #<struct User::Data id=1, name="John", email="john@bar.com">
#
# -- Invalid input --
#
# Created user: #<struct User::Data id=2, name="Jane", email=nil>
# Created user: #<struct User::Data id=3, name="", email=nil>