In software architecture, many principles and patterns have emerged over time. However, a lot of these can be boiled down to a single idea: the "IO to the Boundary" principle. This principle suggests that all input/output operations, like database queries, API calls, or file system interactions, should be pushed to the edges (or boundaries) of your application.
Let's look at what some other ideas tell us:
- Functional Core, Imperative Shell: This principle suggests keeping the core of your application pure and functional, while handling side effects (IO) in an outer layer. This is basically another way of saying "IO to the Boundary."
- Clean Architecture: Proposed by Robert C. Martin, this architecture emphasizes separating concerns and having dependencies point inwards, which fits with keeping IO at the edges.
- Command Query Responsibility Segregation (CQRS): While not mainly about IO boundaries, CQRS often leads to separating read and write operations. This separation can support the "IO to the Boundary" principle by making it easier to isolate and manage IO operations at the system's edges.
Example
- The Client Code interacts with both WeatherAPI (to create an object instance) and WeatherReport (to generate a report).
- WeatherReport uses WeatherAPI internally to fetch temperature data.
- WeatherAPI encapsulates the IO operations, making the actual HTTP request to the External Weather API.
This structure keeps the core application logic (WeatherReport) free from direct IO concerns, pushing those responsibilities to the edges (WeatherAPI and beyond).
Client Code WeatherReport WeatherAPI External Weather API
| | | |
| | | |
| new WeatherAPI() | |
|---------------------------------------> |
| | | |
| new WeatherReport(weather_api) | |
|------------------>| | |
| | | |
| generate_report('New York') | |
|------------------>| | |
| | | |
| | get_temperature('New York') |
| |------------------>| |
| | | |
| | | HTTP GET request |
| | |----------------->|
| | | |
| | | JSON response |
| | |<-----------------|
| | | |
| | temperature | |
| |<------------------| |
| | | |
| formatted report | | |
|<------------------| | |
| | | |
[Application Boundary] [IO Boundary]
Note on oversimplification
From my perspective, the key to effective architecture is finding the right balance between simplification and necessary complexity (sounds vague but that's true..). It's becoming clear that most mid-sized projects don't need the level of complexity they often include. But some do. While I recognize that the real world is more complex, with evolving projects, changing teams, and loss of knowledge when an organization changes, it's crucial to remember that sometimes the best abstraction is no abstraction at all.
Code samples
Let's consider "good" and "bad" examples (I'll be using Ruby and Ruby on Rails to convey the ideas)
Bad example
# app/controllers/weather_controller.rb
class WeatherController < ApplicationController
def report
city = params[:city]
api_key = ENV['WEATHER_API_KEY']
response = HTTP.get("https://api.weather.com/v1/temperature?city=#{city}&key=#{api_key}")
data = JSON.parse(response.body)
temperature = data['temperature']
feels_like = data['feels_like']
report = "Weather Report for #{city}:\n"
report += "Temperature: #{temperature}°C\n"
report += "Feels like: #{feels_like}°C\n"
if temperature > 30
report += "It's hot outside! Stay hydrated."
elsif temperature < 10
report += "It's cold! Bundle up."
else
report += "The weather is mild. Enjoy your day!"
end
render plain: report
end
end
It's evident that the code smells:
- Mixing IO operations (API call) with business logic in the controller.
- Directly parsing and manipulating data in the controller.
- Generating the report text within the controller action.
Good example
# app/controllers/weather_controller.rb
class WeatherController < ApplicationController
def report
result = WeatherReport::Generate.new.call(city: params[:city])
if result.success?
render plain: result.value!
else
render plain: "Error: #{result.failure}", status: :unprocessable_entity
end
end
end
# app/business_processess/weather_report/generate.rb
require 'dry/transaction'
module WeatherReport
class Generate
include Dry::Transaction
step :validate_input
step :fetch_weather_data
step :generate_report
private
def validate_input(city:)
schema = Dry::Schema.Params do
required(:city).filled(:string)
end
result = schema.call(city: city)
result.success? ? Success(city: result[:city]) : Failure(result.errors.to_h)
end
def fetch_weather_data(city:)
result = WeatherGateway.new.fetch(city)
result.success? ? Success(city: city, weather: result.value!) : Failure(result.failure)
end
def generate_report(city:, weather:)
report = WeatherReport.new(city, weather).compose
Success(report)
end
end
end
# app/business_processess/weather_report/weather_gateway.rb
class WeatherGateway
include Dry::Monads[:result]
def fetch(city)
response = HTTP.get("https://api.weather.com/v1/temperature?city=#{city}&key=#{ENV['WEATHER_API_KEY']}")
data = JSON.parse(response.body)
Success(temperature: data['temperature'], feels_like: data['feels_like'])
rescue StandardError => e
Failure("Failed to fetch weather data: #{e.message}")
end
end
# app/business_processess/weather_report/weather_report.rb
class WeatherReport
def initialize(city, weather)
@city = city
@weather = weather
end
def compose
# ...
end
end
What has changed:
- Separating concerns: The controller only handles HTTP-related tasks.
- Using dry-transaction to create a clear flow of operations, with each step having a single responsibility.
- Pushing IO operations (API calls) to the boundary in the WeatherAPI service.
Note on complexity
We should be realistic though, if we're talking about a simple 1-pager app generating the weather reports, well, who cares? It just works and we definitely do not want to introduce any additional layers.
However, if we care about the future extendability, thinking about these kind of things is crucial.
Final note
Many of the architectural principles and patterns we encounter today are not entirely new concepts, but rather evolved or repackaged ideas from the past. I hope that you see now that the "IO to the Boundary" principle is one such idea that has been expressed in various forms over the years.
Comments / suggestions appreciated! 🙏
Top comments (0)