Overview
Clean Architecture defines a system organization where business logic remains independent of external concerns like databases, web frameworks, or user interfaces. Robert C. Martin introduced this pattern to address common problems in software design: tight coupling, difficulty testing, and resistance to change.
The architecture arranges code in concentric circles, where inner circles contain business logic and outer circles contain implementation details. Dependencies point inward only—outer layers depend on inner layers, never the reverse. This dependency rule ensures that business logic never depends on frameworks, databases, or delivery mechanisms.
Core layers include entities (enterprise business rules), use cases (application business rules), interface adapters (controllers, presenters, gateways), and frameworks/drivers (web frameworks, databases, external interfaces). Each layer serves a specific purpose and maintains clear boundaries.
# Directory structure reflecting Clean Architecture layers
lib/
entities/ # Enterprise business rules
user.rb
order.rb
use_cases/ # Application business rules
create_order.rb
process_payment.rb
adapters/ # Interface adapters
controllers/
presenters/
gateways/
frameworks/ # External interfaces
web/
persistence/
The pattern addresses several architectural problems. Traditional layered architectures often result in database-centric designs where business logic depends on database schemas. Clean Architecture inverts these dependencies, placing business logic at the center and treating databases as implementation details that can be swapped without affecting core logic.
Clean Architecture differs from related patterns like Hexagonal Architecture and Onion Architecture in terminology and emphasis, but shares the core principle of dependency inversion. All three patterns separate business logic from infrastructure concerns and enforce dependency rules pointing toward the domain.
Key Principles
The Dependency Rule governs all interactions in Clean Architecture: source code dependencies point only inward. Outer circles know nothing about inner circles. Inner circles declare interfaces that outer circles implement. This rule applies at every level—classes, modules, functions, and data structures.
Entities represent enterprise business rules that would exist regardless of any particular application. An entity encapsulates critical business logic that multiple applications might share. Entities change only when fundamental business requirements change, not when UI or database concerns change.
# Entity - pure business logic with no external dependencies
class Order
attr_reader :id, :items, :customer_id, :status
def initialize(id:, customer_id:, items: [])
@id = id
@customer_id = customer_id
@items = items
@status = :pending
end
def add_item(item)
raise "Cannot modify completed order" if completed?
@items << item
end
def total
items.sum(&:price)
end
def complete
raise "Order must have items" if items.empty?
@status = :completed
end
def completed?
status == :completed
end
end
Use Cases contain application-specific business rules. A use case orchestrates the flow of data between entities and coordinates entity behavior to accomplish a specific application task. Use cases know about entities but remain independent of databases, frameworks, and UI concerns.
# Use Case - application-specific business rules
class CreateOrder
def initialize(order_repository:, notification_service:)
@order_repository = order_repository
@notification_service = notification_service
end
def execute(customer_id:, items:)
order = Order.new(
id: generate_id,
customer_id: customer_id,
items: items
)
@order_repository.save(order)
@notification_service.notify_order_created(order)
{ success: true, order_id: order.id }
rescue => e
{ success: false, error: e.message }
end
private
def generate_id
SecureRandom.uuid
end
end
Interface Adapters convert data between the format most convenient for use cases and entities and the format most convenient for external agencies like databases or web frameworks. Controllers, presenters, and gateways reside in this layer. Controllers receive requests from the web framework and translate them into use case inputs. Presenters format use case outputs for display. Gateways translate between domain objects and database records.
The Dependency Inversion Principle enables inner layers to remain independent. Inner layers define interfaces (contracts) that outer layers must implement. Use cases depend on repository interfaces, not concrete database implementations. This allows swapping databases, frameworks, or external services without modifying business logic.
# Use case depends on abstraction, not implementation
class ProcessPayment
def initialize(payment_gateway:)
@payment_gateway = payment_gateway
end
def execute(order_id:, amount:, payment_method:)
result = @payment_gateway.charge(
amount: amount,
payment_method: payment_method
)
if result.success?
{ success: true, transaction_id: result.transaction_id }
else
{ success: false, error: result.error_message }
end
end
end
# Interface defined by use case layer
module PaymentGateway
def charge(amount:, payment_method:)
raise NotImplementedError
end
end
Boundaries between layers remain strict. Inner layers never reference names, classes, functions, or data structures from outer layers. When use cases need external services, they define interfaces that outer layers implement. When outer layers need to invoke use cases, they do so through defined input/output structures.
Data crossing boundaries follows specific rules. Simple data structures cross boundaries, not entity objects. When a controller passes data to a use case, it sends primitive types or simple data transfer objects, not ActiveRecord models. When a use case returns data, it returns basic data structures that presenters can format, not domain entities.
Design Considerations
Clean Architecture trades implementation complexity for long-term maintainability. The pattern requires more initial setup than simpler architectures—defining interfaces, creating adapters, and organizing code into distinct layers demands effort. Systems with straightforward CRUD operations may not justify this overhead, but applications with complex business logic benefit significantly.
The architecture excels when business logic complexity exceeds infrastructure complexity. Applications with intricate business rules, multiple business constraints, or frequently changing requirements gain the most. E-commerce platforms, financial systems, and healthcare applications typically contain sufficient complexity to warrant Clean Architecture's structure.
Testing becomes dramatically simpler because business logic depends only on abstractions. Tests run without databases, web servers, or external services. A test suite that runs in milliseconds rather than minutes becomes achievable.
# Testing use case without any infrastructure
RSpec.describe CreateOrder do
let(:repository) { instance_double(OrderRepository) }
let(:notifier) { instance_double(NotificationService) }
let(:use_case) { CreateOrder.new(
order_repository: repository,
notification_service: notifier
)}
it "creates order and sends notification" do
items = [Item.new(name: "Widget", price: 10.00)]
allow(repository).to receive(:save)
allow(notifier).to receive(:notify_order_created)
result = use_case.execute(customer_id: "123", items: items)
expect(result[:success]).to be true
expect(repository).to have_received(:save)
expect(notifier).to have_received(:notify_order_created)
end
end
Framework independence protects against framework obsolescence. Rails, Sinatra, or any web framework becomes a plugin rather than the foundation. When a framework reaches end-of-life or better alternatives emerge, migration affects only outer layers. Business logic remains untouched.
Database independence follows the same principle. PostgreSQL, MongoDB, or Redis can be swapped without altering business rules. Development environments might use SQLite while production uses PostgreSQL. Testing might use in-memory storage while production uses a distributed database.
The pattern introduces indirection that some developers find excessive. Every external interaction requires defining an interface, implementing an adapter, and wiring dependencies. Simple operations like saving a record involve multiple classes. Teams must decide whether this indirection serves their specific needs.
Smaller applications or prototypes may not warrant Clean Architecture's ceremony. A startup validating product-market fit needs rapid iteration more than long-term maintainability. A small utility application with minimal business logic gains little from elaborate layering.
Teams transitioning from framework-centric architectures face a learning curve. Developers accustomed to putting business logic in ActiveRecord models or controllers must learn new patterns. The team needs shared understanding of boundaries and dependency rules.
Gradual adoption provides a practical path. New features can follow Clean Architecture while legacy code remains unchanged. Critical business logic can be extracted first, leaving simple CRUD operations in framework-dependent code. This approach allows teams to learn the pattern incrementally.
Ruby Implementation
Ruby's dynamic nature and module system support Clean Architecture's requirements while introducing specific considerations. Duck typing enables dependency inversion without formal interface definitions, but explicit contracts improve clarity in complex systems.
Entities in Ruby should focus on business behavior rather than persistence. Unlike ActiveRecord models that couple business logic with database concerns, entities represent pure domain concepts. They contain no database knowledge, no associations, and no persistence callbacks.
# Entity with rich business behavior
class Account
attr_reader :id, :balance, :account_type
def initialize(id:, balance:, account_type:)
@id = id
@balance = balance
@account_type = account_type
end
def deposit(amount)
raise ArgumentError, "Amount must be positive" unless amount > 0
@balance += amount
end
def withdraw(amount)
raise ArgumentError, "Amount must be positive" unless amount > 0
raise InsufficientFunds if amount > available_balance
@balance -= amount
end
def available_balance
case account_type
when :checking then balance
when :savings then balance - minimum_balance
else raise "Unknown account type"
end
end
private
def minimum_balance
100.00
end
class InsufficientFunds < StandardError; end
end
Use cases coordinate business operations using dependency injection. Constructor injection makes dependencies explicit and testable. Ruby's keyword arguments provide clear dependency specification.
class TransferFunds
def initialize(account_repository:, transaction_repository:, event_publisher:)
@account_repository = account_repository
@transaction_repository = transaction_repository
@event_publisher = event_publisher
end
def execute(from_account_id:, to_account_id:, amount:)
from_account = @account_repository.find(from_account_id)
to_account = @account_repository.find(to_account_id)
return failure("Source account not found") unless from_account
return failure("Destination account not found") unless to_account
from_account.withdraw(amount)
to_account.deposit(amount)
@account_repository.save(from_account)
@account_repository.save(to_account)
transaction = create_transaction(from_account_id, to_account_id, amount)
@transaction_repository.save(transaction)
@event_publisher.publish(:transfer_completed, transaction)
success(transaction_id: transaction.id)
rescue Account::InsufficientFunds
failure("Insufficient funds")
rescue => e
failure("Transfer failed: #{e.message}")
end
private
def create_transaction(from_id, to_id, amount)
Transaction.new(
id: SecureRandom.uuid,
from_account_id: from_id,
to_account_id: to_id,
amount: amount,
timestamp: Time.now
)
end
def success(data = {})
{ success: true }.merge(data)
end
def failure(message)
{ success: false, error: message }
end
end
Repository adapters translate between domain entities and persistence mechanisms. Repositories present a collection-like interface to use cases while handling database operations internally.
# Repository adapter for database persistence
class PostgresAccountRepository
def initialize(database)
@database = database
end
def find(id)
row = @database.execute(
"SELECT * FROM accounts WHERE id = ?",
id
).first
return nil unless row
Account.new(
id: row['id'],
balance: BigDecimal(row['balance']),
account_type: row['account_type'].to_sym
)
end
def save(account)
@database.execute(
"INSERT INTO accounts (id, balance, account_type)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE
SET balance = ?, account_type = ?",
account.id,
account.balance,
account.account_type.to_s,
account.balance,
account.account_type.to_s
)
end
def all
rows = @database.execute("SELECT * FROM accounts")
rows.map { |row| hydrate(row) }
end
private
def hydrate(row)
Account.new(
id: row['id'],
balance: BigDecimal(row['balance']),
account_type: row['account_type'].to_sym
)
end
end
Controllers in Ruby web frameworks serve as adapters between HTTP and use cases. They parse request parameters, invoke use cases, and format responses. Controllers contain no business logic.
# Rails controller as an adapter
class AccountsController < ApplicationController
def transfer
result = transfer_use_case.execute(
from_account_id: params[:from_account_id],
to_account_id: params[:to_account_id],
amount: BigDecimal(params[:amount])
)
if result[:success]
render json: {
transaction_id: result[:transaction_id]
}, status: :created
else
render json: {
error: result[:error]
}, status: :unprocessable_entity
end
end
private
def transfer_use_case
TransferFunds.new(
account_repository: account_repository,
transaction_repository: transaction_repository,
event_publisher: event_publisher
)
end
def account_repository
@account_repository ||= PostgresAccountRepository.new(database)
end
def transaction_repository
@transaction_repository ||= PostgresTransactionRepository.new(database)
end
def event_publisher
@event_publisher ||= RedisEventPublisher.new(redis_client)
end
def database
@database ||= Database.new(ENV['DATABASE_URL'])
end
def redis_client
@redis_client ||= Redis.new(url: ENV['REDIS_URL'])
end
end
Dependency injection containers simplify wiring in Ruby applications. Dry-container provides a lightweight container that manages dependencies.
require 'dry-container'
require 'dry-auto_inject'
class Container
extend Dry::Container::Mixin
register(:database) { Database.new(ENV['DATABASE_URL']) }
register(:redis_client) { Redis.new(url: ENV['REDIS_URL']) }
register(:account_repository) {
PostgresAccountRepository.new(resolve(:database))
}
register(:transaction_repository) {
PostgresTransactionRepository.new(resolve(:database))
}
register(:event_publisher) {
RedisEventPublisher.new(resolve(:redis_client))
}
register(:transfer_funds) {
TransferFunds.new(
account_repository: resolve(:account_repository),
transaction_repository: resolve(:transaction_repository),
event_publisher: resolve(:event_publisher)
)
}
end
Import = Dry::AutoInject(Container)
class AccountsController < ApplicationController
include Import[:transfer_funds]
def transfer
result = transfer_funds.execute(
from_account_id: params[:from_account_id],
to_account_id: params[:to_account_id],
amount: BigDecimal(params[:amount])
)
# Handle result...
end
end
Ruby's module system enables defining explicit contracts even without static typing. Modules document expected interfaces and can validate implementations at runtime during development.
module AccountRepository
def find(id)
raise NotImplementedError
end
def save(account)
raise NotImplementedError
end
def all
raise NotImplementedError
end
end
class PostgresAccountRepository
include AccountRepository
def initialize(database)
@database = database
end
def find(id)
# Implementation
end
def save(account)
# Implementation
end
def all
# Implementation
end
end
Common Patterns
The Repository pattern provides collection-like access to entities while encapsulating storage details. Repositories expose methods like find, save, delete, and query operations that return domain entities, never database records.
class UserRepository
def find_by_email(email)
row = database.execute(
"SELECT * FROM users WHERE email = ?",
email
).first
return nil unless row
hydrate_user(row)
end
def find_active_users
rows = database.execute(
"SELECT * FROM users WHERE status = 'active'"
)
rows.map { |row| hydrate_user(row) }
end
def save(user)
if exists?(user.id)
update_user(user)
else
insert_user(user)
end
end
private
def hydrate_user(row)
User.new(
id: row['id'],
email: row['email'],
name: row['name'],
status: row['status'].to_sym
)
end
def exists?(id)
database.execute(
"SELECT 1 FROM users WHERE id = ?",
id
).any?
end
def insert_user(user)
database.execute(
"INSERT INTO users (id, email, name, status) VALUES (?, ?, ?, ?)",
user.id, user.email, user.name, user.status.to_s
)
end
def update_user(user)
database.execute(
"UPDATE users SET email = ?, name = ?, status = ? WHERE id = ?",
user.email, user.name, user.status.to_s, user.id
)
end
end
The Use Case pattern encapsulates single application operations. Each use case represents one user intent or system operation. Use cases coordinate entities, repositories, and external services to accomplish specific goals.
class RegisterUser
def initialize(user_repository:, email_service:, password_hasher:)
@user_repository = user_repository
@email_service = email_service
@password_hasher = password_hasher
end
def execute(email:, password:, name:)
return failure("Email already registered") if email_taken?(email)
return failure("Invalid email format") unless valid_email?(email)
return failure("Password too weak") unless strong_password?(password)
user = User.new(
id: SecureRandom.uuid,
email: email,
name: name,
password_hash: @password_hasher.hash(password),
status: :pending_verification
)
@user_repository.save(user)
@email_service.send_verification(user.email, user.verification_token)
success(user_id: user.id)
rescue => e
failure("Registration failed: #{e.message}")
end
private
def email_taken?(email)
@user_repository.find_by_email(email) != nil
end
def valid_email?(email)
email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
end
def strong_password?(password)
password.length >= 12 &&
password.match?(/[A-Z]/) &&
password.match?(/[a-z]/) &&
password.match?(/[0-9]/)
end
def success(data)
{ success: true }.merge(data)
end
def failure(message)
{ success: false, error: message }
end
end
The Presenter pattern formats use case output for display. Presenters transform domain data into view-specific formats, handling concerns like date formatting, currency display, and conditional rendering.
class OrderPresenter
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
customer_id: @order.customer_id,
items: formatted_items,
total: formatted_total,
status: status_display,
created_at: formatted_date(@order.created_at)
}
end
def as_html
<<~HTML
<div class="order" data-order-id="#{@order.id}">
<h3>Order #{@order.id}</h3>
<div class="status #{status_class}">#{status_display}</div>
<div class="total">#{formatted_total}</div>
<ul class="items">
#{item_list_html}
</ul>
</div>
HTML
end
private
def formatted_items
@order.items.map do |item|
{
name: item.name,
quantity: item.quantity,
price: format_currency(item.price)
}
end
end
def formatted_total
format_currency(@order.total)
end
def status_display
@order.status.to_s.split('_').map(&:capitalize).join(' ')
end
def status_class
case @order.status
when :completed then 'success'
when :cancelled then 'danger'
when :pending then 'warning'
else 'info'
end
end
def formatted_date(date)
date.strftime("%B %d, %Y at %I:%M %p")
end
def format_currency(amount)
"$%.2f" % amount
end
def item_list_html
@order.items.map do |item|
"<li>#{item.name} - #{item.quantity} × #{format_currency(item.price)}</li>"
end.join("\n")
end
end
The Gateway pattern wraps external services with domain-friendly interfaces. Gateways handle external API communication, data format translation, and error handling while presenting clean interfaces to use cases.
class StripePaymentGateway
def initialize(api_key:)
@api_key = api_key
@client = Stripe::Client.new(api_key)
end
def charge(amount:, currency:, payment_method:, description:)
response = @client.charges.create(
amount: (amount * 100).to_i,
currency: currency,
source: payment_method.token,
description: description
)
PaymentResult.success(
transaction_id: response.id,
amount: amount,
timestamp: Time.at(response.created)
)
rescue Stripe::CardError => e
PaymentResult.failure(
error_code: e.code,
error_message: e.message
)
rescue Stripe::APIError => e
PaymentResult.failure(
error_code: 'api_error',
error_message: 'Payment service temporarily unavailable'
)
end
def refund(transaction_id:, amount:)
response = @client.refunds.create(
charge: transaction_id,
amount: (amount * 100).to_i
)
RefundResult.success(
refund_id: response.id,
amount: amount
)
rescue Stripe::APIError => e
RefundResult.failure(error_message: e.message)
end
end
class PaymentResult
attr_reader :success, :transaction_id, :amount, :timestamp, :error_code, :error_message
def self.success(transaction_id:, amount:, timestamp:)
new(
success: true,
transaction_id: transaction_id,
amount: amount,
timestamp: timestamp
)
end
def self.failure(error_code:, error_message:)
new(
success: false,
error_code: error_code,
error_message: error_message
)
end
def initialize(success:, transaction_id: nil, amount: nil, timestamp: nil,
error_code: nil, error_message: nil)
@success = success
@transaction_id = transaction_id
@amount = amount
@timestamp = timestamp
@error_code = error_code
@error_message = error_message
end
def success?
@success
end
end
The Value Object pattern represents immutable concepts without identity. Value objects enable expressing domain concepts precisely and ensure invariants hold.
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
raise ArgumentError, "Amount cannot be negative" if amount < 0
raise ArgumentError, "Invalid currency" unless valid_currency?(currency)
@amount = amount
@currency = currency
freeze
end
def +(other)
raise ArgumentError, "Currency mismatch" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
def -(other)
raise ArgumentError, "Currency mismatch" unless currency == other.currency
Money.new(amount - other.amount, currency)
end
def *(multiplier)
Money.new(amount * multiplier, currency)
end
def ==(other)
other.is_a?(Money) &&
amount == other.amount &&
currency == other.currency
end
def to_s
"#{currency} #{format('%.2f', amount)}"
end
private
def valid_currency?(currency)
%i[USD EUR GBP JPY].include?(currency)
end
end
Practical Examples
A task management application demonstrates Clean Architecture organization. The system manages tasks, projects, and user assignments with complex business rules around task dependencies and project workflows.
# Entity: Task with business rules
class Task
attr_reader :id, :title, :description, :status, :project_id, :assignee_id, :due_date
def initialize(id:, title:, project_id:, description: nil, assignee_id: nil, due_date: nil)
@id = id
@title = title
@description = description
@project_id = project_id
@assignee_id = assignee_id
@due_date = due_date
@status = :pending
@dependencies = []
end
def assign_to(user_id)
@assignee_id = user_id
end
def add_dependency(task_id)
@dependencies << task_id unless @dependencies.include?(task_id)
end
def start
raise "Cannot start task with incomplete dependencies" unless dependencies_completed?
raise "Task must be assigned" unless @assignee_id
@status = :in_progress
end
def complete
raise "Task not started" unless @status == :in_progress
@status = :completed
end
def overdue?
@due_date && @due_date < Date.today && @status != :completed
end
def blocked?
!dependencies_completed?
end
private
def dependencies_completed?
# Will be checked by use case with repository
@dependencies.empty?
end
end
# Use Case: Complete Task
class CompleteTask
def initialize(task_repository:, notification_service:, activity_logger:)
@task_repository = task_repository
@notification_service = notification_service
@activity_logger = activity_logger
end
def execute(task_id:, completed_by:)
task = @task_repository.find(task_id)
return failure("Task not found") unless task
task.complete
@task_repository.save(task)
@activity_logger.log(
event: :task_completed,
task_id: task.id,
user_id: completed_by,
timestamp: Time.now
)
dependent_tasks = @task_repository.find_dependent_on(task_id)
notify_dependent_tasks(dependent_tasks)
success(task_id: task.id, status: task.status)
rescue => e
failure("Failed to complete task: #{e.message}")
end
private
def notify_dependent_tasks(tasks)
unblocked = tasks.select { |t| !t.blocked? }
unblocked.each do |task|
@notification_service.notify_task_ready(
task_id: task.id,
assignee_id: task.assignee_id
)
end
end
def success(data)
{ success: true }.merge(data)
end
def failure(message)
{ success: false, error: message }
end
end
# Repository Implementation
class SequelTaskRepository
def initialize(database)
@db = database
end
def find(id)
row = @db[:tasks].where(id: id).first
return nil unless row
hydrate_task(row)
end
def save(task)
data = {
title: task.title,
description: task.description,
status: task.status.to_s,
project_id: task.project_id,
assignee_id: task.assignee_id,
due_date: task.due_date
}
if @db[:tasks].where(id: task.id).any?
@db[:tasks].where(id: task.id).update(data)
else
@db[:tasks].insert(data.merge(id: task.id))
end
end
def find_dependent_on(task_id)
rows = @db[:task_dependencies]
.where(dependency_id: task_id)
.select(:task_id)
task_ids = rows.map { |r| r[:task_id] }
tasks = @db[:tasks].where(id: task_ids).all
tasks.map { |row| hydrate_task(row) }
end
private
def hydrate_task(row)
task = Task.new(
id: row[:id],
title: row[:title],
project_id: row[:project_id],
description: row[:description],
assignee_id: row[:assignee_id],
due_date: row[:due_date]
)
# Reconstitute status
task.instance_variable_set(:@status, row[:status].to_sym)
task
end
end
# Controller Adapter
class TasksController
include Import[
:complete_task_use_case,
:task_presenter
]
def complete
result = complete_task_use_case.execute(
task_id: params[:id],
completed_by: current_user_id
)
if result[:success]
task = task_repository.find(result[:task_id])
render json: task_presenter.as_json(task), status: :ok
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end
end
An e-commerce checkout process illustrates coordinating multiple use cases and external services.
# Use Case: Process Checkout
class ProcessCheckout
def initialize(
cart_repository:,
order_repository:,
inventory_service:,
payment_gateway:,
shipping_calculator:,
email_service:
)
@cart_repository = cart_repository
@order_repository = order_repository
@inventory_service = inventory_service
@payment_gateway = payment_gateway
@shipping_calculator = shipping_calculator
@email_service = email_service
end
def execute(cart_id:, shipping_address:, payment_method:)
cart = @cart_repository.find(cart_id)
return failure("Cart not found") unless cart
return failure("Cart is empty") if cart.empty?
# Verify inventory
inventory_check = @inventory_service.check_availability(cart.items)
return failure("Items out of stock: #{inventory_check.unavailable}") unless inventory_check.available?
# Calculate shipping
shipping_cost = @shipping_calculator.calculate(
items: cart.items,
destination: shipping_address
)
# Create order
order = Order.new(
id: SecureRandom.uuid,
customer_id: cart.customer_id,
items: cart.items,
shipping_address: shipping_address,
shipping_cost: shipping_cost
)
# Process payment
total_amount = order.subtotal + shipping_cost.amount
payment_result = @payment_gateway.charge(
amount: total_amount,
currency: :USD,
payment_method: payment_method,
description: "Order #{order.id}"
)
return failure("Payment failed: #{payment_result.error_message}") unless payment_result.success?
# Reserve inventory
@inventory_service.reserve(order.items, order.id)
# Save order
order.confirm_payment(payment_result.transaction_id)
@order_repository.save(order)
# Clear cart
@cart_repository.delete(cart_id)
# Send confirmation
@email_service.send_order_confirmation(
email: order.customer_email,
order: order
)
success(order_id: order.id, total: total_amount)
rescue => e
# Rollback payment if order save fails
@payment_gateway.refund(payment_result.transaction_id) if payment_result&.success?
failure("Checkout failed: #{e.message}")
end
private
def success(data)
{ success: true }.merge(data)
end
def failure(message)
{ success: false, error: message }
end
end
A reporting system demonstrates read-side patterns and query optimization within Clean Architecture.
# Query Use Case for reporting
class GenerateSalesReport
def initialize(sales_repository:, report_formatter:)
@sales_repository = sales_repository
@report_formatter = report_formatter
end
def execute(start_date:, end_date:, group_by: :day, format: :json)
sales_data = @sales_repository.find_in_range(start_date, end_date)
grouped_data = group_sales(sales_data, group_by)
metrics = calculate_metrics(grouped_data)
report = SalesReport.new(
period: { start: start_date, end: end_date },
group_by: group_by,
data: grouped_data,
metrics: metrics
)
formatted_report = @report_formatter.format(report, format)
success(report: formatted_report)
rescue => e
failure("Report generation failed: #{e.message}")
end
private
def group_sales(sales, group_by)
case group_by
when :day
sales.group_by { |s| s.date.to_date }
when :week
sales.group_by { |s| s.date.beginning_of_week }
when :month
sales.group_by { |s| s.date.beginning_of_month }
end
end
def calculate_metrics(grouped_data)
{
total_revenue: grouped_data.values.flatten.sum(&:amount),
average_order_value: grouped_data.values.flatten.sum(&:amount) /
grouped_data.values.flatten.size.to_f,
total_orders: grouped_data.values.flatten.size,
periods: grouped_data.size
}
end
def success(data)
{ success: true }.merge(data)
end
def failure(message)
{ success: false, error: message }
end
end
class SalesReport
attr_reader :period, :group_by, :data, :metrics
def initialize(period:, group_by:, data:, metrics:)
@period = period
@group_by = group_by
@data = data
@metrics = metrics
end
end
Testing Approaches
Testing entities focuses on business logic in isolation. Entity tests verify business rules, state transitions, and invariants without any infrastructure dependencies.
RSpec.describe Order do
describe "#add_item" do
it "adds item to pending order" do
order = Order.new(id: "1", customer_id: "user123")
item = Item.new(name: "Widget", price: 10.00)
order.add_item(item)
expect(order.items).to include(item)
end
it "raises error when adding to completed order" do
order = Order.new(id: "1", customer_id: "user123")
order.instance_variable_set(:@status, :completed)
item = Item.new(name: "Widget", price: 10.00)
expect { order.add_item(item) }.to raise_error("Cannot modify completed order")
end
end
describe "#complete" do
it "completes order with items" do
order = Order.new(id: "1", customer_id: "user123")
order.add_item(Item.new(name: "Widget", price: 10.00))
order.complete
expect(order.completed?).to be true
end
it "raises error when completing empty order" do
order = Order.new(id: "1", customer_id: "user123")
expect { order.complete }.to raise_error("Order must have items")
end
end
end
Use case tests verify orchestration logic using test doubles for dependencies. Tests confirm the use case coordinates entities and services correctly.
RSpec.describe CreateOrder do
let(:order_repository) { instance_double(OrderRepository) }
let(:notification_service) { instance_double(NotificationService) }
let(:use_case) {
CreateOrder.new(
order_repository: order_repository,
notification_service: notification_service
)
}
describe "#execute" do
let(:items) { [Item.new(name: "Widget", price: 10.00)] }
it "creates order and sends notification" do
allow(order_repository).to receive(:save)
allow(notification_service).to receive(:notify_order_created)
result = use_case.execute(customer_id: "123", items: items)
expect(result[:success]).to be true
expect(result[:order_id]).to be_present
expect(order_repository).to have_received(:save)
expect(notification_service).to have_received(:notify_order_created)
end
it "returns failure when repository raises error" do
allow(order_repository).to receive(:save).and_raise("Database error")
allow(notification_service).to receive(:notify_order_created)
result = use_case.execute(customer_id: "123", items: items)
expect(result[:success]).to be false
expect(result[:error]).to include("Database error")
expect(notification_service).not_to have_received(:notify_order_created)
end
end
end
Repository tests verify data persistence and retrieval. In-memory implementations enable fast testing without databases, while integration tests verify actual database behavior.
# Shared examples for any repository implementation
RSpec.shared_examples "order repository" do
describe "#save and #find" do
it "persists and retrieves order" do
order = Order.new(
id: "order123",
customer_id: "user456",
items: [Item.new(name: "Widget", price: 10.00)]
)
repository.save(order)
retrieved = repository.find("order123")
expect(retrieved.id).to eq(order.id)
expect(retrieved.customer_id).to eq(order.customer_id)
expect(retrieved.items.size).to eq(order.items.size)
end
it "returns nil for non-existent order" do
result = repository.find("nonexistent")
expect(result).to be_nil
end
end
end
# Test in-memory implementation
RSpec.describe InMemoryOrderRepository do
let(:repository) { InMemoryOrderRepository.new }
include_examples "order repository"
end
# Test database implementation
RSpec.describe PostgresOrderRepository do
let(:database) { test_database }
let(:repository) { PostgresOrderRepository.new(database) }
before do
database.run("DELETE FROM orders")
end
include_examples "order repository"
end
Integration tests verify the complete flow through all layers. These tests use real implementations rather than test doubles.
RSpec.describe "Complete order flow", type: :integration do
let(:container) { build_test_container }
let(:create_order) { container.resolve(:create_order_use_case) }
let(:complete_order) { container.resolve(:complete_order_use_case) }
let(:order_repository) { container.resolve(:order_repository) }
it "creates and completes order end-to-end" do
items = [
Item.new(name: "Widget", price: 10.00),
Item.new(name: "Gadget", price: 20.00)
]
# Create order
create_result = create_order.execute(
customer_id: "user123",
items: items
)
expect(create_result[:success]).to be true
order_id = create_result[:order_id]
# Verify order saved
order = order_repository.find(order_id)
expect(order).not_to be_nil
expect(order.status).to eq(:pending)
# Complete order
complete_result = complete_order.execute(order_id: order_id)
expect(complete_result[:success]).to be true
# Verify completion
completed_order = order_repository.find(order_id)
expect(completed_order.status).to eq(:completed)
end
end
def build_test_container
Container.new.tap do |c|
c.register(:database) { test_database }
c.register(:notification_service) { FakeNotificationService.new }
c.register(:order_repository) {
PostgresOrderRepository.new(c.resolve(:database))
}
c.register(:create_order_use_case) {
CreateOrder.new(
order_repository: c.resolve(:order_repository),
notification_service: c.resolve(:notification_service)
)
}
c.register(:complete_order_use_case) {
CompleteOrder.new(
order_repository: c.resolve(:order_repository)
)
}
end
end
Reference
Layer Responsibilities
| Layer | Purpose | Dependencies | Examples |
|---|---|---|---|
| Entities | Enterprise business rules | None | User, Order, Account, Payment |
| Use Cases | Application business rules | Entities | CreateOrder, ProcessPayment, RegisterUser |
| Interface Adapters | Data format conversion | Use Cases, Entities | Controllers, Presenters, Repositories, Gateways |
| Frameworks & Drivers | External interfaces | Interface Adapters | Web Framework, Database, External APIs |
Dependency Rules
| Allowed | Prohibited | Reason |
|---|---|---|
| Use Case depends on Entity interface | Entity depends on Use Case | Entities must remain pure business logic |
| Controller depends on Use Case | Use Case depends on Controller | Use cases should not know about delivery mechanisms |
| Repository implements interface defined by Use Case | Use Case depends on concrete Repository | Enables testing without database |
| Gateway wraps external API | Use Case calls external API directly | Isolates external dependencies |
Common Use Case Structure
| Component | Purpose | Required |
|---|---|---|
| Constructor | Accept dependencies via injection | Yes |
| Execute method | Perform single operation | Yes |
| Input validation | Verify preconditions | Recommended |
| Entity coordination | Orchestrate business logic | As needed |
| Repository calls | Persist/retrieve data | As needed |
| Return value | Success/failure indicator with data | Yes |
Repository Interface Pattern
| Method Pattern | Purpose | Return Type |
|---|---|---|
| find(id) | Retrieve single entity | Entity or nil |
| save(entity) | Persist entity | void or boolean |
| delete(id) | Remove entity | void or boolean |
| all | Retrieve all entities | Array of entities |
| find_by_attribute(value) | Query by specific field | Entity or nil |
| find_where(criteria) | Query with conditions | Array of entities |
Entity Design Guidelines
| Principle | Description | Example |
|---|---|---|
| No persistence knowledge | Entities never reference database | No ActiveRecord inheritance |
| Rich behavior | Business logic in entity methods | Order.complete, Account.withdraw |
| Invariant protection | Constructor enforces valid state | Validate required fields |
| Immutability where appropriate | Value objects should be frozen | Money, Address, DateRange |
| No framework coupling | No Rails, Sinatra, or framework dependencies | Pure Ruby objects |
Testing Strategy
| Test Type | Purpose | Dependencies | Speed |
|---|---|---|---|
| Entity tests | Verify business rules | None | Milliseconds |
| Use case tests | Verify orchestration | Test doubles | Milliseconds |
| Repository tests | Verify persistence | In-memory or database | Seconds |
| Integration tests | Verify complete flows | Real implementations | Seconds |
| System tests | Verify end-to-end scenarios | Full stack | Minutes |
Dependency Injection Patterns
| Pattern | Implementation | When to Use |
|---|---|---|
| Constructor injection | Pass dependencies to initialize | Most use cases and adapters |
| Setter injection | Provide setter methods | Optional dependencies |
| Container-based | Use DI container like dry-container | Complex dependency graphs |
| Factory pattern | Factory creates with dependencies | Multiple implementation choices |
Error Handling Strategies
| Strategy | Use Case | Implementation |
|---|---|---|
| Result object | Return success/failure indicator | Return hash with success and data/error keys |
| Exception raising | Unrecoverable errors | Raise custom exception classes |
| Null object | Handle missing data gracefully | Return null object instead of nil |
| Validation object | Collect multiple errors | Return validation result with error collection |
Architecture Decision Criteria
| Consider Clean Architecture When | Consider Simpler Architecture When |
|---|---|
| Complex business logic | Simple CRUD operations |
| Multiple delivery mechanisms | Single interface type |
| Long-term maintenance expected | Short-lived prototype |
| Team experienced with pattern | Small team learning basics |
| Frequent requirement changes | Stable, well-understood domain |
| Critical business application | Internal utility tool |