HTTP and the Database Don't Belong in the Same File
Read a file’s imports before you read anything else. If the list includes an HTTP library and a database driver, that file knows about two outside worlds, and it’s probably doing at least two jobs. The boundary between them has blurred, and you pay for it every time you change that code or try to test it.
The imports are the dependency list
An import is a dependency the file declares out loud. You can learn what a file is allowed to touch without reading a line of the body.
HTTP and the database are both edges of your system, and each has its own vocabulary and failure modes. HTTP has status codes, headers, JSON parsing, and clients that hang up halfway through a request. The database has connections, transactions, SQL, and constraint violations. A file that deals with both has to speak both languages, and so does anyone reading it.
What the blur looks like
Here’s a Sinatra endpoint that creates an order. It’s short, and it’s typical.
```ruby require “sinatra” require “json” require “pg”
post “/orders” do payload = JSON.parse(request.body.read) halt 422 if payload[“items”].empty?
| total = payload[“items”].sum { | item | item[“price”] * item[“quantity”] } |
db = PG.connect(ENV.fetch(“DATABASE_URL”)) db.exec_params( “INSERT INTO orders (customer_id, total) VALUES ($1, $2)”, [payload[“customer_id”], total] )
status 201 { total: total }.to_json end ```
Three jobs are tangled together here: parsing a request, applying a business rule (an order needs items, and its total is price times quantity), and writing a row. The business rule is the part that matters most, and it’s trapped between the other two.
Now someone asks for orders to come in from a nightly CSV import as well. The total calculation lives inside an HTTP route, so the import either duplicates it or fakes a request to reach it. Later the orders table moves to a different database, and you’re editing a file full of request handling to do it.
Try to test it
Testing that one line of arithmetic means booting the app with a test client, posting a JSON body, and pointing the whole thing at a real Postgres. A test for multiplication now needs a database.
Tests like that are slow, and the ones that depend on a network connection or shared state are the ones that flake. So people write fewer of them. The rule with the most edge cases ends up with the fewest tests, because it’s the most expensive one to reach.
Split along the imports
Pull the three jobs into three places, and let the imports keep them there.
```ruby # order_total.rb: no requires at all module OrderTotal def self.call(items) raise ArgumentError, “an order needs at least one item” if items.empty?
items.sum { |item| item.fetch(:price) * item.fetch(:quantity) } end end ```
The business rule goes in a plain module with no imports. The database code goes in a repository class that requires pg and knows nothing about HTTP. The route requires sinatra and json, parses the request, calls the other two, and formats the response. It’s the only file that knows a request exists.
The test for the rule is one line, and it runs in microseconds:
ruby
assert_equal 1000, OrderTotal.call([{ price: 500, quantity: 2 }])
The CSV import calls OrderTotal and the repository directly. A database move touches the repository and nothing else. The route shrinks to translation: HTTP in, HTTP out.
None of this is new. Alistair Cockburn called it hexagonal architecture, or ports and adapters, and Gary Bernhardt’s “functional core, imperative shell” has the same shape. The core holds the rules and depends on nothing. The edges talk to the outside world and depend on the core. Starting is cheap: notice when one file reaches for both edges, and move one of them out.
When it’s fine to mix them
A fifty-line script that pulls JSON from an API and inserts the rows can live in one file. Nobody is going to extend it, and splitting it would be ceremony.
The red flag is for code that will grow: application code, anything with business rules, anything a second caller is going to want. If you find an HTTP client and has_many in the same class, or a fetch call and a SQL query in the same module, stop and ask which job that file is supposed to do. Then give the other job its own file.
Read the imports first. It’s the cheapest architecture review you’ll ever run, and it takes about five seconds.