In this post, I want to talk about decoupling, testing, and the test pyramid. I’d like to answer why they are important and how to approach them. Based on that, the main points of this post will be:
Note: In this post I will assume an environment where maintainability and testability are important. The focus will be on that, not on small projects.
Before diving deeper, we should clarify why having a good test suite matters. The obvious reason is to avoid bugs. Just like in a football match where defenders protect the goal, tests act as defenders that protect our code from potential issues. But there is more: a solid test suite also gives us confidence to refactor, add new features, and move fast without constantly fearing that something will break.
Some people think that adding tests slows development down. In reality, the opposite is true: with a solid test suite you gain better maintainability and can move faster with confidence. Automated tests reduce the amount of repetitive checks, letting you focus manual effort on what truly matters.
There are different types of tests we can use to ensure the quality of our code. Each type has a different scope, cost, and purpose. The most common categories are:
The test pyramid is a concept that helps us structure our tests in a balanced and efficient way. Imagine a pyramid: at the base, we have a large number of unit tests, in the middle a smaller number of integration tests, and at the top a few end-to-end or acceptance tests.
The idea is simple: the lower layers are fast, reliable, and cheap to maintain, while the upper layers are slower, more complex, and expensive to run. By having a solid foundation of unit tests, we can catch most issues early, reduce the risk of regressions, and make refactoring much safer. Integration and end-to-end tests then validate that all pieces work together and meet business requirements.
Following the test pyramid helps teams maintain a healthy balance: enough coverage to ensure quality without relying too heavily on slow or brittle end-to-end tests. It also reinforces the importance of writing modular, decoupled code that is easy to test.
If we invert the test pyramid, putting too many end-to-end tests at the top and only a few unit tests at the bottom, we will face several problems. End-to-end tests are slower to run (Hi CI hahahaha), more brittle, and harder to maintain. Relying heavily on them means that catching bugs early becomes difficult, and small changes can break multiple tests, slowing down development and reducing confidence in the code.
On the other hand, having a strong foundation of unit tests allows us to catch most issues quickly, run tests frequently, and make refactoring or adding new features much safer. The test pyramid exists to guide us towards an efficient balance: fast, reliable tests at the base and fewer, focused tests at the top.
Based on the previous explanation, we want to have more unit tests, right? So how can we achieve that? What is the best way to make our code easier to test? The answer is decoupling and following an architecture to do it.
Now that we know the different types of tests and their roles in the test pyramid, it's time to ask an important question: why is code decoupling essential? Even the best tests won't help much if your code is tightly coupled. When components depend too much on each other, writing unit tests becomes difficult, integration tests become fragile, and making changes without introducing bugs is risky.
Decoupling your code not only makes it easier to test, but also improves maintainability, scalability, and overall code quality. Let's explore why this matters and how to achieve it.
This point is quite interesting to me and makes me think a lot for several reasons. Generally, when we pick a framework (most of them, not all), the "best practices" and architecture it promotes often show tightly coupled code. Even what they call unit testing, in my personal opinion, isn’t really unit testing: everything is coupled, so they have to provide tools, helpers, etc., to be able to test the code, including setting up a database, because the code is so tightly coupled. And this often leads to inverting the test pyramid.
That’s why the architectures provided by frameworks often don’t scale. When a project grows, it usually starts to be modularized to improve maintainability, but many times the code is still coupled and developers have to keep using the framework’s tools to test that coupled code.
Currently, in my day-to-day work, I’m on a Rails project, which is a completely different ecosystem from what I’ve worked with in the past, for several reasons. For example, Rails provides everything you need to develop: you don’t need an ORM or a library for websockets, etc., the framework gives you all of that and it’s very stable. I come from environments where it’s not unusual to change the ORM or even the framework itself. Where decoupling is extremely important, this makes me wonder if it matters as much in an ecosystem like Ruby, where you’re not going to switch the ORM or the framework because everything revolves around Rails, and at the same time, the tools are very stable (from what I’ve seen so far).
This makes me wonder: if there are applications handling huge amounts of data in the Rails ecosystem, with many developers maintaining the code, everything works, companies grow, and money is made, then maybe this idea of decoupled architecture is just something that developers like to promote to seem “cool”? Hahaha.
Well… personally, I don’t think so, for several reasons:
As we can see, there are many advantages to decoupling our code. Returning to the Rails ecosystem, we can look at how Shopify addressed this. Shopify proposed an architecture for decoupling, though I think they removed the link, but I found this, which is like a “complaint” about the architecture haha, and also this one. Basically, Shopify proposes an architecture based on "actions," which are "use cases," keeping business logic in the models (not Active Record), and using the repository pattern to encapsulate Active Record. In other words, it’s a clean architecture.
After understanding why the test pyramid is important, why we need mostly unit tests, and why decoupling matters, let's see how we could write good tests in practice.
Before diving into the examples, I want to clarify that I come from an environment where in the last years I worked with clean architecture and tactical DDD patterns (and some strategic ones). What I will show here is mainly influenced by clean architecture principles, more or less similar to what Shopify proposed.
In a standard Rails project, logic is often mixed in Active Record models or controllers. This makes unit testing difficult because the code is tightly coupled with the database and other infrastructure.
# app/models/order.rb
class Order < ApplicationRecord
has_many :order_items
def total_amount
order_items.sum("quantity * price")
end
def pay!
# directly calls payment gateway
result = PaymentGateway.charge(user_id: user_id, amount: total_amount)
raise "Payment failed" unless result.success?
update!(status: "paid")
end
end
# spec/models/order_spec.rb
require 'rails_helper'
RSpec.describe Order, type: :model do
describe "#pay!" do
let(:order) { Order.create!(user_id: 1) }
let!(:item) { order.order_items.create!(product_id: 1, quantity: 2, price: 10) }
it "updates the order status to paid when payment succeeds" do
result = double("PaymentResult", success?: true)
allow(PaymentGateway).to receive(:charge).and_return(result)
order.pay!
expect(order.status).to eq("paid")
end
it "raises an error when payment fails" do
result = double("PaymentResult", success?: false)
allow(PaymentGateway).to receive(:charge).and_return(result)
expect { order.pay! }.to raise_error("Payment failed")
end
end
end
Notice that these tests rely on the real database and framework internals. Even though we stub the payment gateway, the test still creates records in the database and depends on Active Record behavior. This makes the test slower, harder to isolate, and more brittle.
The problem is not only performance: conceptually, this is not a true unit test.
Instead of testing just the business logic of the Order
entity,
we are also testing persistence, Rails callbacks, and schema configuration.
That means a failure in the database, a migration change, or even Rails internals can cause the test to break,
even if the business logic itself is correct.
In practice, this approach leads to:
That’s why Rails provides helpers like rails_helper
to manage this complexity.
But the underlying issue remains: you are testing production objects in a coupled way,
rather than isolating the business logic (Rails provide factory bot to encapsulate production objects too, but you still use active record models).
In contrast, with Clean Architecture, the business logic lives in entities and use cases. Active Record or other frameworks are just infrastructure adapters. Tests are fast, isolated, and easy to maintain.
# src/domain/order_entity.rb
class OrderEntity
attr_reader :user_id, :items, :total_amount, :status
def initialize(user_id:, items:)
raise "No items provided" if items.empty?
@user_id = user_id
@items = items
@total_amount = calculate_total
@status = "pending"
end
def pay(payment_charger)
result = payment_charger.charge(user_id: user_id, amount: total_amount)
raise "Payment failed" unless result.success?
@status = "paid"
end
private
def calculate_total
items.sum { |item| item.price * item[:quantity] }
end
end
# src/domain/order_repository.rb
# Abstract repository (interface)
class OrderRepository
def save(order_entity)
raise NotImplementedError, "Subclasses must implement the save method"
end
end
# src/infrastructure/active_record_order_repository.rb
class ActiveRecordOrderRepository < OrderRepository
def save(order_entity)
record = Order.find_or_initialize_by(id: order_entity.id)
record.assign_attributes(
user_id: order_entity.user_id,
items: order_entity.items,
total_amount: order_entity.total_amount,
status: order_entity.status
)
record.save!
end
end
# src/application/create_order.rb
class CreateOrder
def initialize(order_repository:, payment_charger:, event_bus:)
@order_repository = order_repository
@payment_charger = payment_charger
@event_bus = event_bus
end
def call(input)
order = OrderEntity.new(user_id: input[:user_id], items: input[:items])
order.pay(@payment_charger)
@order_repository.create(order)
@event_bus.publish(:order_created, order_id: order.object_id, user_id: order.user_id, total: order.total_amount)
end
end
NOTE: In this example, we pass a payment_charger (an interface) to the entity to process the payment. This allows the entity to remain independent of any specific payment implementation, making it easier to test and replace implementations if needed.
Additionally, we introduce the concept of an event bus. The entity publishes events that other objects can subscribe to, without needing to know about them. This further decouples responsibilities and enforces the Single Responsibility Principle, as the entity focuses solely on business logic and the event handlers take care of side effects like notifications, logging, or further processing.
Besides that, another important point is that the use case just orchestrates the things, while the business logic resides in the entity, avoiding anemic models. We move the business logic to deeper layer, the domain.
# spec/application/create_order_spec.rb
RSpec.describe CreateOrder do
let(:order_repository) { FakeOrderRepository.new }
let(:payment_service) { FakePaymentService.new }
let(:event_bus) { FakeEventBus.new }
let(:use_case) { CreateOrder.new(order_repository: order_repository, payment_service: payment_service, event_bus: event_bus) }
let(:input) { { user_id: 1, items: [{ product_id: 1, quantity: 2 }] } }
it "raises error if no items are provided" do
expect { use_case.call(user_id: 1, items: []) }.to raise_error("No items provided")
end
it "creates an order, pays it, and publishes an event" do
order = use_case.call(input)
expect(order.status).to eq("paid")
expect(order.total_amount).to eq(20)
expect(order_repository.orders.size).to eq(1)
expect(published_events.size).to eq(1)
expect(published_events.first[:total]).to eq(order.total_amount)
end
it "raises error if payment fails" do
failing_payment_service = FakePaymentService.new(success: false)
use_case = CreateOrder.new(order_repository: order_repository, payment_service: failing_payment_service, event_bus: event_bus)
expect { use_case.call(input) }.to raise_error("Payment failed")
end
end
NOTE: One important thing here is that I don't use test doubles provided by the framework, and here is my explanation of why I prefer to use my own test doubles.
With this approach (the Clean Architecture one), unit tests are fast, isolated, and focused on the business logic. The infrastructure (database, Active Record, external services) is just injected and can be replaced or mocked easily. This aligns perfectly with the principles of the test pyramid and Clean Architecture.
In this project you can see more things.
In this section, let's explore how to build a solid test suite following the test pyramid. It's important not only to test the right things in each layer, but also to know which exceptions or failures make sense to cover.
The table below summarizes what to focus on and what exceptions to test in each layer:
Layer | Test Focus | Exceptions / Errors to Test | Do Not Test |
---|---|---|---|
Unit | Pure logic of entity/use case | Business rules, validation, expected errors (e.g., invalid order data, payment failure) | Database, external services, component wiring |
Integration | Component collaboration | Repository failures, external service errors, event bus issues | Internal entity logic already covered by unit tests |
Acceptance | Full user flow including API endpoints, HTTP response, and client interactions | User-facing errors and flow-breaking issues, such as invalid input, unauthorized access, payment rejected, DB or external service unavailable | Internal business logic of entities or use cases (already covered by unit tests), detailed component wiring |
By clearly defining what to test and what not to test at each layer, and including user-facing errors in E2E tests, you ensure a fast, reliable, and maintainable test suite that provides confidence at every level of the system.
In summary, building a solid test suite is not just about writing tests for the sake of coverage. It's about understanding why testing is important, knowing the different types of tests available, and structuring them according to the test pyramid to maximize speed, reliability, and maintainability. Decoupling your code is essential to make testing feasible, whether at the unit, integration, or acceptance level. By following these principles and focusing on what to test in each layer, you can ensure your code is robust, easier to maintain, and ready to scale as your application grows. A well-structured test suite gives you confidence that changes won’t break existing functionality and that your system behaves correctly from the perspective of both developers and users.