Building Solid Software: From Decoupling to the Test Pyramid

Linking software architecture with testing strategies for more reliable systems

Posted by Martín Peveri on September 13, 2025 · 22 mins read

Building Solid Software: From Decoupling to the Test Pyramid

Linking software architecture with testing strategies for more reliable systems

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.

  • Why is testing important?
  • What types of tests do we have?
  • What is the test pyramid and why is it important?
  • Why do I need to decouple my code?
  • How can I write good tests?
  • How can I achieve a solid test suite?

Why is testing important?

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.

What types of tests do we have?

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:

  • Unit tests: Small, fast tests that focus on individual functions or components in isolation.
  • Integration tests: Verify that different modules or services work correctly together.
  • Acceptance tests: Validate that the system meets business requirements and behaves as expected from the user's perspective.
  • End-to-end (E2E) tests: Simulate real user flows across the entire system, from the UI to the database.

What is the test pyramid and why is it important?

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.

Test Pyramid Diagram

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.

Why do I need to decouple my code?

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:

  • Not just for testability, but for maintainability. For example, even if a library is very stable, it may change, and if everything is tightly coupled, updating it might require changing many parts of your code.
  • Having unit tests that don’t depend on any database or other infrastructure, so they are lightweight and you can test the business logic and its behavior in isolation.
  • Being able to replace implementations easily (for example, changing the database, queue system, or an external provider) without affecting the rest of the application. You just need to change the adapter.
  • Making the code easier to read and understand: each layer or module has a clear responsibility, reducing cognitive complexity.
  • Enabling team scalability: different developers can work on separate modules without interfering with each other, since dependencies are well defined.
  • Reducing the risk of cascading errors: a localized change shouldn’t break the entire system if dependencies are decoupled.
  • Facilitates test automation in CI/CD: When the code is decoupled, tests are faster and don’t depend on external infrastructure, which speeds up continuous integration pipelines.

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.

How can I write good tests?

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.

The Typical Rails Way

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:

  • Coupling between business logic and the database/framework.
  • Overlapping responsibilities in tests (mixing logic validation with persistence validation).
  • Reduced clarity, because it’s harder to tell whether a failure is due to logic or infrastructure.

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).

Clean Architecture Way

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.

How can I achieve a solid test suite?

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.

  • Unit Tests (Base of the Pyramid): These tests focus on the business logic of individual components in isolation. For example, in the CreateOrder use case, you would test that it calculates totals correctly, handles payment logic properly, and raises errors when necessary. Here, it makes sense to test exceptions related to business rules or validation, like a payment failing or invalid order data. You do not test database interactions, external services, or component wiring here. We double these dependencies.
  • Integration Tests (Middle Layer): These tests check how different components collaborate. For instance, you could test that CreateOrder correctly orchestrates the entity, repository, payment service, and event bus. Here, you may test exceptions like repository failures, failed external service calls, or event publishing issues. You do not need to cover internal business logic already verified by unit tests.
  • Acceptance Tests (Top Layer): These tests ensure that the system works correctly from the user's perspective. For example, creating an order through an API endpoint and verifying that it is persisted, payment is processed, and events are triggered. Here, it makes sense to test that the endpoint responds with the correct HTTP status and payload, and that any errors (payment rejected, invalid input, unauthorized access, etc.) are properly returned to the user. You do not need to retest the internal business logic of the entity or use case, since that is already covered by unit tests. The main purpose of acceptance tests is to validate the end-to-end flow including the HTTP layer, ensuring the system works as expected from the client’s point of view.

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.

Summary

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.