Embracing Hexagonal Architecture in Ruby
Software architecture is the foundation of any reliable and long-lasting program. Being seasoned engineers, we continuously seek design patterns that allow for code clarity, adaptability, and testability. Hexagonal Architecture, or Ports and Adapters, is an attractive remedy to most architectural issues. This post delves into this foundational pattern, discovering its inner principles and everyday applicability via Ruby-based examples.
What is hexagonal architecture?
Hexagonal Architecture seeks to separate the inherent business logic of an application from outside dependencies, i.e., databases, user interfaces, or external services. The separation is brought about through the establishment of well-defined boundaries, usually depicted in the form of a hexagon, between the core factors. The hexagon portrays the business rules and domain model that are not reliant on any specific technology.
The role of ports and adapters
The ports
in Hexagonal Architecture are interfaces (often implemented as modules in Ruby) that define how the core interacts with the outside world. These ports specify the operations the core can perform and the data it can exchange. Adapters
are concrete implementations of these interfaces for specific technologies. For example, a port might define how to persist user data, while adapters would provide implementations for different databases like PostgreSQL or MongoDB.
Why embrace hexagonal architecture?
The benefits of Hexagonal Architecture are numerous. Firstly, it dramatically improves testability. Because the core is isolated, we can easily mock external dependencies during unit testing. Secondly, it enhances maintainability. Changes to external systems, like switching databases, don't require modifications to the core logic. Thirdly, it fosters flexibility. We can easily swap out adapters for different technologies without affecting the core. Lastly, it promotes a cleaner, more modular design, making the codebase easier to understand and maintain.
Implementing hexagonal architecture: a practical example in Ruby
Let's consider a simple user registration example. We can define a UserRegistration
module that specifies the register
operation. This port is implemented by a UserRegistration
within the core domain. For persisting user data, we can create a PostgresUserRepository
adapter that includes the same module for a PostgreSQL database. Similarly, a MongoUserRepository
adapter could be created for MongoDB.
# Domain Model (Core)
class User
attr_reader :email, :name
def initialize(email, name)
@email = email
@name = name
end
def valid?
[email protected]? && [email protected]? && @email.include?("@") && [email protected]? && [email protected]?
end
end
# Port (Interface)
module UserRepository
def save(user)
raise NotImplementedError
end
def find_by_email(email)
raise NotImplementedError
end
end
module UserRegistrationService
def register(email, name)
raise NotImplementedError
end
end
# Core Service (Use Case)
class UserRegistration
include UserRegistrationService
def initialize(user_repository) # Dependency Injection
@user_repository = user_repository
end
def register(email, name)
user = User.new(email, name)
if user.valid?
@user_repository.save(user)
return true # Registration successful
else
return false # Registration failed
end
end
end
# Adapters (Implementations)
class PostgresUserRepository
include UserRepository
def save(user)
# ... PostgreSQL database interaction ...
puts "Saving user #{user.email} to Postgres" # Simulated database interaction
# Example using a hypothetical database library:
# @db_connection.exec("INSERT INTO users (email, name) VALUES ($1, $2)", [user.email, user.name])
end
def find_by_email(email)
# ... PostgreSQL database interaction ...
puts "Finding user #{email} in Postgres" # Simulated database interaction
# Example using a hypothetical database library:
# result = @db_connection.exec("SELECT email, name FROM users WHERE email = $1", [email])
# ... process result ...
end
end
class InMemoryUserRepository
include UserRepository
def initialize
@users = {}
end
def save(user)
@users[user.email] = user
puts "Saving user #{user.email} in memory"
end
def find_by_email(email)
@users[email]
end
end
# Example Usage
# Using Postgres
postgres_repo = PostgresUserRepository.new
registration_service = UserRegistration.new(postgres_repo) # Injecting the dependency
registration_service.register("[email protected]", "Test User")
# Switching to In-Memory (for testing or other purposes)
in_memory_repo = InMemoryUserRepository.new
registration_service = UserRegistration.new(in_memory_repo) # Injecting a different dependency
registration_service.register("[email protected]", "Another User")
# Example of finding a user
found_user = in_memory_repo.find_by_email("[email protected]")
puts found_user.name if found_user
This structure allows us to easily switch between databases by simply changing the adapter without touching the core UserRegistration
. Dependency injection, as shown above, is a common way to provide the core service with the appropriate adapter.
When to use hexagonal architecture
Hexagonal Architecture is particularly beneficial for complex applications with multiple external dependencies. It's also valuable when you anticipate changes in technology or require high testability. While it might add some initial complexity, the long-term benefits in terms of maintainability and flexibility make it a worthwhile investment.
Common problems
I see these typical problems when teams want to go with hexagonal architecture:
- Taking the "hexagon" too literally: Some programmers adhere too rigidly to the visual representation of the hexagon, which can lead to unnecessarily complicating the project. It's important to remember that the hexagon is just a metaphor, and the key principles are isolation and ports.
- Too many ports and adapters: An excessive number of ports and adapters can make the project difficult to understand and increase its complexity. You should strive to find the right balance, defining ports only for key interactions with the external world.
- Improper use of dependency injection: Dependency injection is a key element of Hexagonal Architecture, but its incorrect use can lead to problems with testing and code maintenance. It's important to understand the principles of dependency injection and apply them consistently.
- Ignoring testing: Hexagonal Architecture was created, among other things, to facilitate testing. Ignoring this benefit and not writing unit tests for the core layer is a big mistake.
Conclusion
Hexagonal Architecture provides a robust and elegant solution for building maintainable and flexible applications. By isolating the core business logic from external dependencies, it empowers developers to adapt to changing requirements and technologies with ease. Embracing this pattern is a significant step towards creating high-quality, long-lasting software.
Remember - use that pattern only when you see a big bonus in your system!
Happy hexagoning!