Overview
Separation of Concerns (SoC) organizes code into distinct sections where each section handles a specific aspect of functionality. This principle reduces coupling between different parts of a system, making code easier to understand, test, and modify. A concern represents any piece of functionality or requirement that affects the code, such as business logic, data access, user interface, logging, or error handling.
The concept originated from Edsger Dijkstra's 1974 paper "On the role of scientific thought" where he emphasized separating different aspects of a problem to manage complexity. Modern software development applies this principle across multiple levels: from individual methods and classes to entire system architectures.
Systems that violate SoC exhibit tight coupling between unrelated functionality. A class that handles database operations, business logic, and HTML rendering simultaneously becomes difficult to test because testing one aspect requires setting up infrastructure for all three. Changes to database schema require modifications throughout the class, increasing the risk of introducing bugs in unrelated functionality.
# Violation: Multiple concerns mixed together
class UserReport
def generate(user_id)
# Database concern
conn = PG.connect(dbname: 'myapp')
result = conn.exec("SELECT * FROM users WHERE id = #{user_id}")
# Business logic concern
total = calculate_total(result)
# Presentation concern
html = "<html><body><h1>#{result['name']}</h1>"
html += "<p>Total: #{total}</p></body></html>"
html
end
end
SoC addresses three fundamental questions: what does this code do, where should this functionality live, and how do different parts interact. The principle applies to method design, class organization, module structure, and system architecture. A well-separated system contains components with clear boundaries and minimal knowledge of each other's internal implementation.
Key Principles
Single Responsibility forms the foundation of SoC. Each component performs one conceptual task and changes for one reason. A class responsible for user authentication should not also handle email delivery or database connection pooling. When business rules change, only the business logic component requires modification. When the database schema changes, only the data access component needs updates.
Cohesion measures how strongly related the responsibilities within a component are. High cohesion indicates that all methods and data within a component work toward the same goal. A UserValidator class with methods validate_email, validate_password, and validate_age demonstrates high cohesion. Adding a send_welcome_email method reduces cohesion by introducing an unrelated concern.
Coupling describes the degree of interdependence between components. Loose coupling allows components to interact through well-defined interfaces without knowing internal implementation details. A service that calls payment_processor.charge(amount) exhibits loose coupling. A service that directly modifies payment_processor.gateway.connection.last_transaction exhibits tight coupling, making the code fragile and difficult to change.
Abstraction hides implementation details behind stable interfaces. Components depend on abstract concepts rather than concrete implementations. Code that depends on a Logger interface rather than a specific FileLogger class can work with any logging implementation. The interface defines what operations are available; implementations determine how those operations execute.
# Abstraction through interface
class PaymentService
def initialize(payment_gateway)
@gateway = payment_gateway
end
def process(amount)
@gateway.charge(amount)
end
end
# Different implementations satisfy the interface
class StripeGateway
def charge(amount)
# Stripe-specific implementation
end
end
class PayPalGateway
def charge(amount)
# PayPal-specific implementation
end
end
Information Hiding restricts access to component internals. Public interfaces expose only the operations other components need; internal state and helper methods remain private. This prevents external code from depending on implementation details that might change. A class might store data in an array internally but expose methods that suggest a different structure, allowing the internal representation to change without affecting clients.
Modularity organizes code into self-contained units with minimal dependencies. Each module encapsulates related functionality and exposes a small, stable interface. Changes within a module do not propagate to other modules unless the interface changes. This isolation enables independent development, testing, and deployment of different system parts.
The principle extends beyond code structure to concern lifecycle and timing. Runtime concerns include logging, error handling, security, and performance monitoring. These cross-cutting concerns affect multiple components but should not be tangled with business logic. Compile-time concerns include code organization, dependency management, and build configuration. Development-time concerns include testing, debugging, and documentation.
Design Considerations
Apply SoC when complexity reaches the point where multiple developers struggle to understand how changes affect the system. Small scripts or prototypes benefit less from strict separation because the overhead of organizing concerns exceeds the maintenance benefits. As systems grow and multiple developers collaborate, clear boundaries become necessary.
Consider the nature of changes the system experiences. Systems with frequent changes to specific areas benefit from isolating those areas. An e-commerce platform might change pricing rules frequently while inventory management remains stable. Separating pricing logic from inventory logic allows pricing changes without risking inventory bugs. Systems with infrequent, global changes benefit less from fine-grained separation.
Team structure influences separation decisions. Conway's Law observes that system design mirrors communication structure. If separate teams handle frontend and backend development, a clear separation between presentation and business logic aligns with team boundaries. If one small team handles all aspects, extremely fine-grained separation might create more coordination overhead than benefit.
Performance requirements sometimes conflict with clean separation. A high-performance system might combine concerns to reduce overhead. An object-relational mapper provides clean separation between business objects and database access but introduces performance costs. Systems with extreme performance requirements might need more coupled code, accepting maintenance costs for speed gains. The decision depends on whether the performance gain justifies the maintenance burden.
# Clean separation with ORM
class OrderService
def complete_order(order_id)
order = Order.find(order_id)
order.status = 'completed'
order.save
NotificationService.send_confirmation(order)
end
end
# Performance-optimized with direct SQL
class OptimizedOrderService
def complete_order(order_id)
DB.transaction do
DB.exec("UPDATE orders SET status = 'completed' WHERE id = ?", order_id)
email = DB.exec("SELECT email FROM users WHERE id = (SELECT user_id FROM orders WHERE id = ?)", order_id).first
EmailService.send_to(email, :order_confirmation)
end
end
end
Testing requirements often drive separation decisions. Code that mixes concerns becomes difficult to test in isolation. A method that validates input, queries a database, and sends an email requires setting up database fixtures and email mocking for every test. Separating these concerns allows testing validation logic with simple unit tests, testing database operations separately, and testing email delivery independently.
The cost of wrong abstractions exceeds the cost of small duplication. Premature separation based on speculative future needs creates unnecessary complexity. When two pieces of code look similar but serve different purposes, forcing them to share an abstraction creates coupling between unrelated concerns. Wait until the separation becomes obviously beneficial before extracting shared code.
Migration strategy affects separation decisions in existing systems. Gradually separating concerns in a legacy codebase requires different approaches than building a new system with clear boundaries from the start. The strangler pattern wraps legacy components with new interfaces, progressively moving functionality into properly separated modules. This incremental approach reduces risk compared to large-scale rewrites.
Ruby Implementation
Ruby provides several mechanisms for implementing SoC at different levels of granularity. The language's object-oriented nature, module system, and metaprogramming capabilities enable various separation strategies.
Classes and Objects represent the fundamental unit of separation. Each class encapsulates related data and behavior, hiding internal state behind method interfaces. Instance variables remain private; methods define the public contract. Ruby's convention of defining attr_reader, attr_writer, and attr_accessor makes explicit which attributes other objects can access.
class Invoice
attr_reader :items, :created_at
def initialize
@items = []
@created_at = Time.now
end
def add_item(item)
@items << item
end
def total
@items.sum(&:price)
end
def tax
total * tax_rate
end
private
def tax_rate
0.08
end
end
Modules provide namespace separation and behavior sharing without inheritance. Modules group related classes and prevent naming conflicts. The module acts as a namespace boundary, clearly indicating which classes belong to the same concern.
module Authentication
class Session
def initialize(user)
@user = user
@created_at = Time.now
end
def expired?
Time.now - @created_at > 3600
end
end
class TokenGenerator
def generate
SecureRandom.hex(32)
end
end
end
module Billing
class Session
def initialize(transaction_id)
@transaction_id = transaction_id
end
end
end
Concerns in Rails extract shared behavior into modules that mix into multiple classes. This separates cross-cutting functionality from core class responsibilities. A concern defines both instance and class methods, along with dependencies on other modules.
# app/models/concerns/publishable.rb
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where(published: true) }
validates :published_at, presence: true, if: :published?
end
def publish
update(published: true, published_at: Time.current)
end
def unpublish
update(published: false)
end
class_methods do
def recent_publications
published.order(published_at: :desc).limit(10)
end
end
end
class Article < ApplicationRecord
include Publishable
end
class BlogPost < ApplicationRecord
include Publishable
end
Service Objects extract business logic from models and controllers. Each service handles one specific operation, receiving dependencies through initialization rather than reaching out to global state. This pattern separates orchestration logic from domain entities.
class CreateOrder
def initialize(user, cart, payment_processor)
@user = user
@cart = cart
@payment_processor = payment_processor
end
def call
ActiveRecord::Base.transaction do
order = Order.create!(user: @user, total: @cart.total)
@cart.items.each { |item| order.line_items.create!(item: item) }
@payment_processor.charge(@user.payment_method, order.total)
order
end
end
end
# Usage separates concerns in controller
class OrdersController < ApplicationController
def create
service = CreateOrder.new(
current_user,
current_cart,
PaymentProcessor.new
)
@order = service.call
redirect_to @order
rescue PaymentError => e
flash[:error] = e.message
render :checkout
end
end
Repositories separate data access from business logic. The repository provides an interface for querying and persisting objects without exposing database details. Business logic operates on domain objects without knowing whether they come from SQL, NoSQL, or an API.
class UserRepository
def find(id)
record = User.find(id)
map_to_domain(record)
end
def find_by_email(email)
record = User.find_by(email: email)
map_to_domain(record) if record
end
def save(user)
record = User.find_or_initialize_by(id: user.id)
record.update!(
name: user.name,
email: user.email
)
user.id = record.id
user
end
private
def map_to_domain(record)
DomainUser.new(
id: record.id,
name: record.name,
email: record.email
)
end
end
Decorators separate presentation logic from domain models. The decorator wraps a domain object and adds display-specific methods without polluting the domain model with view concerns.
class UserDecorator
def initialize(user)
@user = user
end
def display_name
"#{@user.first_name} #{@user.last_name}"
end
def member_since
@user.created_at.strftime("%B %Y")
end
def avatar_url
@user.avatar_url || default_avatar_url
end
private
def default_avatar_url
"https://example.com/default-avatar.png"
end
def method_missing(method, *args, &block)
@user.send(method, *args, &block)
end
def respond_to_missing?(method, include_private = false)
@user.respond_to?(method, include_private) || super
end
end
# In view
user = UserDecorator.new(current_user)
user.display_name # => "John Doe"
user.email # => delegates to underlying user object
Common Patterns
Layered Architecture organizes code into horizontal layers where each layer provides services to the layer above and uses services from the layer below. Typical layers include presentation, application, domain, and infrastructure. Each layer depends only on layers below it, never on layers above.
# Presentation Layer
class UsersController < ApplicationController
def create
result = UserRegistration.new(user_params).execute
if result.success?
redirect_to result.user
else
@errors = result.errors
render :new
end
end
end
# Application Layer
class UserRegistration
def initialize(params)
@params = params
end
def execute
user = User.new(@params)
if user.valid?
UserRepository.new.save(user)
WelcomeEmail.new(user).deliver
Result.success(user)
else
Result.failure(user.errors)
end
end
end
# Domain Layer
class User
attr_accessor :id, :email, :name
def valid?
email.present? && email.include?('@')
end
end
# Infrastructure Layer
class UserRepository
def save(user)
record = ActiveRecord::Base.connection.execute(
"INSERT INTO users (email, name) VALUES (?, ?)",
user.email, user.name
)
user.id = record['id']
user
end
end
Model-View-Controller (MVC) separates application concerns into data management, user interface, and control flow. The model contains business logic and data access. The view handles presentation. The controller coordinates between model and view, processing user input and selecting appropriate responses.
Rails implements MVC with specific conventions. Models inherit from ActiveRecord, combining domain logic with persistence. Views use ERB templates for HTML generation. Controllers inherit from ActionController, handling HTTP requests and responses. This separation allows changing the user interface without modifying business logic.
Hexagonal Architecture (Ports and Adapters) places business logic at the center, isolated from external concerns. Ports define interfaces the application needs. Adapters implement those interfaces for specific technologies. The application code depends on ports, not adapters, allowing technology changes without modifying core logic.
# Port (interface)
module PaymentGateway
def charge(amount, payment_method)
raise NotImplementedError
end
end
# Core Application
class CheckoutService
def initialize(payment_gateway)
@gateway = payment_gateway
end
def checkout(order)
@gateway.charge(order.total, order.payment_method)
order.mark_complete
end
end
# Adapters
class StripeAdapter
include PaymentGateway
def charge(amount, payment_method)
Stripe::Charge.create(
amount: amount,
source: payment_method.stripe_token
)
end
end
class TestAdapter
include PaymentGateway
def charge(amount, payment_method)
# No actual charging in tests
true
end
end
# Usage
service = CheckoutService.new(
Rails.env.production? ? StripeAdapter.new : TestAdapter.new
)
Command Query Responsibility Segregation (CQRS) separates read and write operations into different models. Commands modify state but return no data. Queries return data but cause no side effects. This separation optimizes each concern independently and clarifies which operations change system state.
# Commands (writes)
class CreateUserCommand
def initialize(params)
@params = params
end
def execute
User.create!(@params)
nil # Commands return nothing
end
end
class UpdateUserCommand
def initialize(user_id, params)
@user_id = user_id
@params = params
end
def execute
User.find(@user_id).update!(@params)
nil
end
end
# Queries (reads)
class UserQuery
def self.find_by_email(email)
User.where(email: email).first
end
def self.active_users
User.where(active: true).order(:name)
end
def self.user_statistics
{
total: User.count,
active: User.where(active: true).count,
inactive: User.where(active: false).count
}
end
end
# Usage
CreateUserCommand.new(email: 'test@example.com').execute
users = UserQuery.active_users
Dependency Injection separates object creation from object use. Components receive dependencies through constructor parameters or setter methods rather than creating them directly. This inverts control flow and reduces coupling between components.
# Without DI - tight coupling
class OrderProcessor
def process(order)
mailer = OrderMailer.new
logger = Logger.new(STDOUT)
mailer.send_confirmation(order)
logger.info("Processed order #{order.id}")
end
end
# With DI - loose coupling
class OrderProcessor
def initialize(mailer:, logger:)
@mailer = mailer
@logger = logger
end
def process(order)
@mailer.send_confirmation(order)
@logger.info("Processed order #{order.id}")
end
end
# Composition root
mailer = Rails.env.production? ? OrderMailer.new : TestMailer.new
logger = Logger.new(Rails.root.join('log', 'orders.log'))
processor = OrderProcessor.new(mailer: mailer, logger: logger)
Event-Driven Architecture separates components through asynchronous events. Publishers emit events when significant actions occur. Subscribers listen for relevant events and react accordingly. Components remain unaware of each other; they share only event definitions.
# Event system
module Events
@subscribers = Hash.new { |h, k| h[k] = [] }
def self.subscribe(event_type, &block)
@subscribers[event_type] << block
end
def self.publish(event_type, data)
@subscribers[event_type].each { |subscriber| subscriber.call(data) }
end
end
# Publisher
class Order
def complete
update(status: 'completed')
Events.publish(:order_completed, self)
end
end
# Subscribers
Events.subscribe(:order_completed) do |order|
OrderMailer.confirmation_email(order).deliver_later
end
Events.subscribe(:order_completed) do |order|
InventoryService.update_stock(order)
end
Events.subscribe(:order_completed) do |order|
AnalyticsService.track_conversion(order)
end
Practical Examples
Example: Refactoring a God Class
A monolithic class handles user management, authentication, and notifications. Separating these concerns improves testability and maintainability.
# Before: All concerns mixed
class UserManager
def create_user(email, password)
user = User.new(email: email)
user.password_hash = BCrypt::Password.create(password)
DB.connection.execute(
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
user.email, user.password_hash
)
Mailer.send_email(
to: email,
subject: "Welcome!",
body: "Thanks for signing up"
)
session[:user_id] = user.id
user
end
end
# After: Separated concerns
class UserRepository
def save(user)
DB.connection.execute(
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
user.email, user.password_hash
)
user
end
end
class PasswordService
def hash(password)
BCrypt::Password.create(password)
end
def verify(password, hash)
BCrypt::Password.new(hash) == password
end
end
class UserRegistration
def initialize(user_repo:, password_service:, mailer:, session_manager:)
@user_repo = user_repo
@password_service = password_service
@mailer = mailer
@session_manager = session_manager
end
def register(email, password)
user = User.new(email: email)
user.password_hash = @password_service.hash(password)
@user_repo.save(user)
@mailer.send_welcome_email(user)
@session_manager.login(user)
user
end
end
Example: Extracting Cross-Cutting Concerns
Logging and error handling appear throughout an application. Moving these to dedicated components removes duplication and centralizes behavior changes.
# Before: Logging scattered throughout
class PaymentService
def charge(amount)
puts "Charging $#{amount}"
result = gateway.charge(amount)
puts "Charge result: #{result}"
result
rescue => e
puts "Error: #{e.message}"
raise
end
end
class OrderService
def create(items)
puts "Creating order with #{items.size} items"
order = Order.create(items: items)
puts "Created order #{order.id}"
order
rescue => e
puts "Error: #{e.message}"
raise
end
end
# After: Centralized logging
class Logger
def info(message)
puts "[INFO] #{Time.now}: #{message}"
end
def error(message, exception)
puts "[ERROR] #{Time.now}: #{message}"
puts " #{exception.class}: #{exception.message}"
puts exception.backtrace.take(5).map { |line| " #{line}" }
end
end
module Loggable
def with_logging(operation_name)
logger.info("Starting #{operation_name}")
result = yield
logger.info("Completed #{operation_name}")
result
rescue => e
logger.error("Failed #{operation_name}", e)
raise
end
private
def logger
@logger ||= Logger.new
end
end
class PaymentService
include Loggable
def charge(amount)
with_logging("payment charge of $#{amount}") do
gateway.charge(amount)
end
end
end
class OrderService
include Loggable
def create(items)
with_logging("order creation with #{items.size} items") do
Order.create(items: items)
end
end
end
Example: Separating Validation from Business Logic
Validation rules often mix with business operations. Extracting validation into dedicated objects clarifies both concerns.
# Before: Validation mixed with logic
class TransferService
def transfer(from_account, to_account, amount)
if amount <= 0
raise "Amount must be positive"
end
if from_account.balance < amount
raise "Insufficient funds"
end
if to_account.frozen?
raise "Destination account is frozen"
end
from_account.balance -= amount
to_account.balance += amount
Transaction.create(
from: from_account,
to: to_account,
amount: amount
)
end
end
# After: Separated validation
class TransferValidator
def initialize(from_account, to_account, amount)
@from_account = from_account
@to_account = to_account
@amount = amount
@errors = []
end
def valid?
validate_amount
validate_source_balance
validate_destination_status
@errors.empty?
end
attr_reader :errors
private
def validate_amount
@errors << "Amount must be positive" if @amount <= 0
end
def validate_source_balance
@errors << "Insufficient funds" if @from_account.balance < @amount
end
def validate_destination_status
@errors << "Destination account is frozen" if @to_account.frozen?
end
end
class TransferService
def transfer(from_account, to_account, amount)
validator = TransferValidator.new(from_account, to_account, amount)
unless validator.valid?
raise ValidationError, validator.errors.join(', ')
end
execute_transfer(from_account, to_account, amount)
end
private
def execute_transfer(from_account, to_account, amount)
from_account.balance -= amount
to_account.balance += amount
Transaction.create(
from: from_account,
to: to_account,
amount: amount
)
end
end
Example: Separating Configuration from Code
Hard-coded configuration values create coupling between environment-specific settings and application logic. Extracting configuration allows different settings for different environments without code changes.
# Before: Configuration embedded in code
class EmailService
def send_email(to, subject, body)
smtp = Net::SMTP.new('smtp.gmail.com', 587)
smtp.enable_starttls
smtp.start('mydomain.com', 'user@mydomain.com', 'password123') do |client|
message = "From: noreply@mydomain.com\nTo: #{to}\nSubject: #{subject}\n\n#{body}"
client.send_message(message, 'noreply@mydomain.com', to)
end
end
end
# After: Configuration separated
class EmailConfig
def self.load
new(
host: ENV.fetch('SMTP_HOST'),
port: ENV.fetch('SMTP_PORT').to_i,
domain: ENV.fetch('SMTP_DOMAIN'),
username: ENV.fetch('SMTP_USERNAME'),
password: ENV.fetch('SMTP_PASSWORD'),
from_address: ENV.fetch('SMTP_FROM_ADDRESS')
)
end
attr_reader :host, :port, :domain, :username, :password, :from_address
def initialize(host:, port:, domain:, username:, password:, from_address:)
@host = host
@port = port
@domain = domain
@username = username
@password = password
@from_address = from_address
end
end
class EmailService
def initialize(config)
@config = config
end
def send_email(to, subject, body)
smtp = Net::SMTP.new(@config.host, @config.port)
smtp.enable_starttls
smtp.start(@config.domain, @config.username, @config.password) do |client|
message = "From: #{@config.from_address}\nTo: #{to}\nSubject: #{subject}\n\n#{body}"
client.send_message(message, @config.from_address, to)
end
end
end
# Usage
config = EmailConfig.load
service = EmailService.new(config)
Reference
Core Concepts
| Concept | Definition | Primary Benefit |
|---|---|---|
| Separation of Concerns | Organizing code so each section addresses a distinct aspect of functionality | Reduces complexity by isolating changes to specific areas |
| Single Responsibility | Each component has one reason to change | Improves maintainability and testability |
| Cohesion | Degree to which component elements belong together | High cohesion indicates well-separated concerns |
| Coupling | Degree of interdependence between components | Low coupling enables independent changes |
| Abstraction | Hiding implementation details behind interfaces | Reduces dependencies on specific implementations |
| Information Hiding | Restricting access to component internals | Prevents fragile dependencies on internal details |
Ruby Implementation Patterns
| Pattern | Purpose | Key Classes/Modules |
|---|---|---|
| Service Objects | Extract business logic from models and controllers | Plain Ruby class with call or execute method |
| Concerns | Share behavior across multiple classes | Module with ActiveSupport::Concern |
| Repositories | Separate data access from business logic | Plain Ruby class wrapping ActiveRecord |
| Decorators | Add presentation logic without modifying models | Plain Ruby class delegating to domain object |
| Form Objects | Handle complex form input and validation | Plain Ruby class with ActiveModel::Model |
| Query Objects | Encapsulate complex database queries | Plain Ruby class returning relation or array |
| Policy Objects | Centralize authorization logic | Plain Ruby class with query methods |
Architectural Patterns
| Pattern | Structure | Best For |
|---|---|---|
| Layered Architecture | Horizontal layers with unidirectional dependencies | Enterprise applications with clear tiers |
| MVC | Model, View, Controller with specific responsibilities | Web applications with user interfaces |
| Hexagonal Architecture | Core logic surrounded by adapters | Applications requiring technology flexibility |
| CQRS | Separate models for reads and writes | Systems with different read/write patterns |
| Event-Driven | Components communicate through events | Loosely coupled, scalable systems |
| Microservices | Independent services with separate databases | Large systems with team autonomy |
Separation Indicators
| Indicator | Good Separation | Poor Separation |
|---|---|---|
| Testing | Can test each concern independently | Must set up multiple concerns for any test |
| Changes | Changes affect single component | Changes ripple through multiple components |
| Understanding | Each component has clear purpose | Purpose unclear without reading entire codebase |
| Reuse | Components usable in different contexts | Components tightly bound to specific context |
| Dependencies | Components depend on abstractions | Components depend on concrete implementations |
Common Mistakes
| Mistake | Description | Solution |
|---|---|---|
| Premature Abstraction | Creating separation before patterns emerge | Wait for duplication to reveal natural boundaries |
| Anemic Domain Model | All logic in services, models only hold data | Move business logic into domain objects |
| God Objects | Single class handling multiple unrelated concerns | Split into focused, single-purpose classes |
| Hidden Dependencies | Components accessing global state or singletons | Make dependencies explicit through injection |
| Over-Engineering | Creating excessive layers for simple problems | Match separation granularity to actual complexity |
| Leaky Abstractions | Implementation details visible through interfaces | Design interfaces independent of implementation |
Decision Framework
| Question | Guides Toward | Guides Away From |
|---|---|---|
| Do multiple developers work on this code? | More separation | Less separation |
| Does this area change frequently? | Isolate changing parts | Keep stable parts together |
| Are there natural boundaries in the domain? | Align with domain concepts | Arbitrary technical splits |
| Does this need independent testing? | Separate testable units | Combine related logic |
| Will this run in different contexts? | Extract reusable components | Context-specific implementation |
| Are there performance constraints? | May need tighter coupling | Clean separation preferred |