DEV Community

Alexander Shagov
Alexander Shagov

Posted on

The "IO to the Boundary" principle

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

  1. The Client Code interacts with both WeatherAPI (to create an object instance) and WeatherReport (to generate a report).
  2. WeatherReport uses WeatherAPI internally to fetch temperature data.
  3. 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)