Overview
Layered architecture organizes application code into horizontal strata, where each layer serves a specific role in the system. The pattern emerged from early mainframe systems and gained widespread adoption through enterprise application development. Modern frameworks like Ruby on Rails reflect layered thinking, though they adapt the traditional model to web application needs.
The architecture enforces a unidirectional dependency rule: upper layers depend on lower layers, but lower layers remain unaware of upper layers. This constraint creates predictable data flow and reduces coupling between system components. A typical three-tier implementation divides the application into presentation, business logic, and data access layers.
The pattern addresses several software engineering challenges. It provides a clear structure for organizing code as applications grow. It enables teams to work on different layers concurrently without conflicts. It allows replacement of individual layers without rewriting the entire system. The architecture also maps cleanly to physical deployment tiers, where layers run on separate machines for scalability.
# Traditional three-tier structure in Ruby
class UserController
def create
user_service = UserService.new
user = user_service.register(params)
render json: user
end
end
class UserService
def register(attributes)
user_repository = UserRepository.new
user_repository.create(attributes)
end
end
class UserRepository
def create(attributes)
User.create(attributes)
end
end
Key Principles
Separation of Concerns: Each layer handles a distinct aspect of the application. The presentation layer manages user interaction and display formatting. The business logic layer enforces rules and coordinates operations. The data access layer handles persistence and retrieval. This separation prevents entanglement of unrelated responsibilities.
Dependency Direction: Dependencies flow downward through the stack. The presentation layer calls the business logic layer. The business logic layer calls the data access layer. The data access layer interacts with the database. This creates a directed acyclic graph of dependencies that prevents circular references.
Layer Isolation: Changes within a layer should not force changes in other layers, provided the layer interface remains stable. The presentation layer can switch from REST to GraphQL without modifying business logic. The data access layer can migrate from PostgreSQL to MongoDB without affecting the business rules. This isolation reduces the blast radius of changes.
Abstraction Levels: Each layer operates at a different level of abstraction. The presentation layer thinks in terms of HTTP requests and JSON responses. The business logic layer thinks in terms of domain operations and validation rules. The data access layer thinks in terms of queries and transactions. Code at each level speaks its own vocabulary.
Interface Contracts: Layers communicate through defined interfaces rather than concrete implementations. The business logic layer depends on a repository interface, not a specific PostgreSQL repository. This dependency inversion enables testing and allows swapping implementations without modifying clients.
The traditional model includes these layers from top to bottom:
Presentation Layer: Handles all user interaction, input validation for format, and output formatting. In web applications, this includes controllers, views, serializers, and API endpoints. The layer translates HTTP requests into operations the business layer understands and converts domain objects into HTTP responses.
Business Logic Layer: Contains domain rules, workflow coordination, and transaction management. This layer validates business constraints (credit limits, eligibility rules), orchestrates operations across multiple entities, and maintains data consistency. Domain services, use case classes, and business validators live here.
Data Access Layer: Manages persistence, query construction, and transaction handling. This includes repositories, query builders, and ORM mappings. The layer abstracts away database-specific details and provides a domain-oriented interface for data operations.
Database Layer: The actual database management system exists outside the application code but forms the foundation of the stack. Some models include this as a separate layer, others consider it infrastructure supporting the data access layer.
# Layer isolation through interfaces
module Repositories
class UserRepositoryInterface
def find(id)
raise NotImplementedError
end
def save(user)
raise NotImplementedError
end
end
end
class PostgresUserRepository < Repositories::UserRepositoryInterface
def find(id)
User.find(id)
end
def save(user)
user.save
end
end
class BusinessLogicService
def initialize(user_repository: PostgresUserRepository.new)
@user_repository = user_repository
end
def promote_user(user_id)
user = @user_repository.find(user_id)
user.role = 'premium'
@user_repository.save(user)
end
end
Design Considerations
Layered architecture fits scenarios where clear separation of concerns outweighs the overhead of additional abstractions. Enterprise applications benefit from explicit layers because multiple teams work on different parts of the system simultaneously. Applications with complex business rules benefit because the business logic layer provides a clear location for encoding those rules. Applications that need to support multiple interfaces (web, mobile API, admin panel) benefit because they share the business logic layer while implementing different presentation layers.
The architecture adds indirection that some applications do not need. Simple CRUD applications may find the overhead excessive. Microservices with focused responsibilities may achieve adequate separation without explicit layering. Performance-critical systems may find the layer traversal cost unacceptable.
Rigid vs Relaxed Layering: Strict layered architecture prohibits layers from skipping intermediaries. The presentation layer cannot directly access the database; all requests pass through the business logic layer. Relaxed layering allows upper layers to access any lower layer. The presentation layer might query the database directly for read-only list operations while routing writes through the business logic layer. Relaxed layering improves performance but reduces isolation.
Open vs Closed Layers: Closed layers must be traversed; calling layers cannot skip them. Open layers can be bypassed. Marking the business logic layer as closed forces all requests through business validation, even simple reads. Marking it open allows the presentation layer to bypass it for trivial operations. Most architectures use closed layers for write operations and consider opening layers for read operations.
Layer Granularity: Coarse-grained layers contain broad responsibilities. The business logic layer handles all domain concerns. Fine-grained layers split responsibilities further. The architecture might separate read models from write models, or domain services from application services. Finer granularity increases complexity but improves targeted changes.
Vertical Slicing vs Horizontal Layers: Pure layered architecture organizes code horizontally. All controllers live together, all services live together, all repositories live together. Vertical slicing organizes code by feature. The user registration feature includes its controller, service, and repository in one directory. Hybrid approaches use layers within feature slices.
Trade-offs to evaluate:
The architecture increases code volume. Each layer adds classes and interfaces. A simple operation touches multiple files. This overhead makes sense for complex domains but adds friction for simple applications.
The architecture can harm performance. Each layer crossing has cost. Function calls accumulate. Object serialization between layers adds overhead. Caching strategies must account for multiple layers. Microservices using layered architecture within each service compound this effect.
The architecture makes following a request path more difficult. Developers trace execution through multiple classes and indirection points. Debugging requires stepping through several layers. Understanding data transformation from HTTP request to database query requires examining multiple files.
The architecture fights against some framework designs. Ruby on Rails emphasizes convention over configuration and fast development. Rails encourages placing business logic in Active Record models, which collapses the business and data access layers. Forcing strict layering onto Rails requires fighting framework conventions.
Implementation Approaches
Traditional Three-Tier: Divides the application into presentation, business logic, and data access. Each tier runs on separate physical machines or containers. The web server handles presentation, the application server runs business logic, and the database server manages persistence. This approach maps directly to network topology and enables horizontal scaling of each tier independently.
Implementation involves creating clear module boundaries. The presentation tier imports business logic interfaces but never imports data access code. The business logic tier imports data access interfaces but never imports presentation code. Dependency injection connects the layers at runtime.
Four-Tier with Service Layer: Adds an application service layer between the presentation and domain layers. Controllers become thin adapters that delegate to application services. Application services orchestrate use cases by coordinating domain services and repositories. Domain services contain pure business logic with no infrastructure concerns.
This approach separates HTTP concerns from use case orchestration. The application service layer handles transaction boundaries, logging, and authorization checks. The domain layer focuses exclusively on business rules. Testing becomes simpler because domain logic has no web framework dependencies.
Domain-Driven Design Layers: Organizes layers around the domain model. The domain layer sits at the center with no dependencies on other layers. The application layer orchestrates domain objects to fulfill use cases. The infrastructure layer implements technical concerns like persistence and messaging. The presentation layer adapts between HTTP and application service interfaces.
This inverts the traditional dependency direction. The domain layer defines repository interfaces that the infrastructure layer implements. The application layer depends on abstractions that infrastructure provides. This allows the domain model to evolve independently of technical decisions.
# DDD-style layered architecture
module Domain
class Order
attr_reader :items, :status
def initialize(items:)
@items = items
@status = :pending
end
def place
raise "Order is empty" if items.empty?
@status = :placed
end
end
module Repositories
class OrderRepository
def save(order)
raise NotImplementedError
end
end
end
end
module Infrastructure
class PostgresOrderRepository < Domain::Repositories::OrderRepository
def save(order)
OrderRecord.create(
items: order.items,
status: order.status
)
end
end
end
module Application
class PlaceOrderService
def initialize(order_repository:)
@order_repository = order_repository
end
def execute(items:)
order = Domain::Order.new(items: items)
order.place
@order_repository.save(order)
end
end
end
module Presentation
class OrdersController
def create
service = Application::PlaceOrderService.new(
order_repository: Infrastructure::PostgresOrderRepository.new
)
service.execute(items: params[:items])
render json: { status: :created }
end
end
end
Hexagonal Architecture Layers: Places the domain at the center with ports defining required and provided interfaces. Adapters implement ports to connect the domain to external systems. Inbound adapters (HTTP controllers, message handlers) invoke the domain. Outbound adapters (database repositories, API clients) satisfy domain dependencies.
This approach emphasizes testability. The domain has no framework dependencies. Tests replace real adapters with test doubles. The application runs with different adapter combinations for development, testing, and production environments.
Vertical Slice Architecture: Organizes layers within feature slices rather than across the application. Each feature contains its own presentation, business logic, and data access code. Features share infrastructure code but remain independent otherwise. This reduces coupling between features at the cost of potential duplication.
Each slice can choose appropriate patterns. Simple features use thin layers. Complex features use rich layers. Features evolve independently without coordinating changes across the entire application.
Common Patterns
Repository Pattern: Encapsulates data access behind a collection-like interface. Repositories expose methods that reflect domain operations (find_active_users, save_order) rather than database operations (SELECT, INSERT). The pattern isolates query construction and OR/M mapping from business logic.
class UserRepository
def find_active_users
User.where(status: 'active').order(:created_at)
end
def find_by_email(email)
User.find_by(email: email)
end
def save(user)
user.save!
end
end
class UserService
def initialize(repository: UserRepository.new)
@repository = repository
end
def activate_account(email)
user = @repository.find_by_email(email)
user.activate
@repository.save(user)
end
end
Service Layer Pattern: Defines application boundaries and establishes transaction control. Service objects represent use cases or operations. Each service method implements a complete user action with defined inputs and outputs. Services orchestrate domain objects and coordinate repository calls.
Services handle cross-cutting concerns like authorization, logging, and error handling. They establish transaction boundaries to maintain consistency. Services provide stable interfaces that controllers and background jobs invoke.
class OrderFulfillmentService
def initialize(
order_repo: OrderRepository.new,
inventory_repo: InventoryRepository.new,
notification_service: NotificationService.new
)
@order_repo = order_repo
@inventory_repo = inventory_repo
@notification_service = notification_service
end
def fulfill_order(order_id)
ActiveRecord::Base.transaction do
order = @order_repo.find(order_id)
order.items.each do |item|
@inventory_repo.decrement_stock(item.product_id, item.quantity)
end
order.mark_as_fulfilled
@order_repo.save(order)
@notification_service.send_fulfillment_notice(order)
end
end
end
Data Transfer Object Pattern: Transfers data between layers using simple objects without behavior. DTOs decouple layer interfaces from internal representations. The presentation layer receives DTOs from the business layer and serializes them to JSON. The business layer receives DTOs from the presentation layer and constructs domain objects.
This pattern prevents exposing internal structure to external consumers. Domain objects contain business logic and might include circular references or lazy-loaded associations. DTOs contain only data needed for the current operation in a flat structure.
class UserDTO
attr_accessor :id, :name, :email, :role
def initialize(id:, name:, email:, role:)
@id = id
@name = name
@email = email
@role = role
end
def self.from_domain(user)
new(
id: user.id,
name: user.full_name,
email: user.email,
role: user.role.to_s
)
end
end
class UserService
def get_user_details(user_id)
user = User.find(user_id)
UserDTO.from_domain(user)
end
end
class UsersController
def show
dto = UserService.new.get_user_details(params[:id])
render json: dto
end
end
Facade Pattern: Provides a simplified interface to complex subsystems within a layer. Facades hide internal complexity and reduce coupling between layers. A service facade might coordinate multiple domain services and repositories behind a single interface.
class OrderFacade
def initialize
@order_service = OrderService.new
@payment_service = PaymentService.new
@shipping_service = ShippingService.new
end
def place_order(order_params, payment_params, shipping_params)
order = @order_service.create_order(order_params)
payment = @payment_service.process_payment(payment_params)
shipment = @shipping_service.schedule_shipment(shipping_params)
{
order: order,
payment: payment,
shipment: shipment
}
end
end
Anti-Pattern: Anemic Domain Model: Creates domain objects that contain only data with no behavior. All logic lives in service objects. This defeats the purpose of object-oriented design and reduces the business logic layer to a procedural script. Symptoms include models with only getters and setters, service objects with names like UserService that contain all user-related logic, and violation of the "Tell, Don't Ask" principle.
Anti-Pattern: Layer Leakage: Occurs when lower layers depend on upper layers or when layers skip intermediaries inappropriately. Controllers that directly call repositories bypass business logic validation. Domain models that reference HTTP requests or session state create upward dependencies. Data access objects that return ActiveRecord instances instead of domain objects couple consumers to OR/M choices.
Ruby Implementation
Ruby on Rails provides components that map to layered architecture but encourages patterns that diverge from strict layering. Rails emphasizes developer productivity and convention over explicit structure. Understanding both traditional layering and Rails conventions enables choosing appropriate patterns for each application.
Presentation Layer in Rails: Controllers handle HTTP concerns and delegate to service objects or directly to models. Views render HTML or JSON responses. Serializers convert domain objects to API responses. Routes map URLs to controller actions.
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
service = Orders::CreateService.new(current_user)
result = service.call(order_params)
if result.success?
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:items, :shipping_address)
end
end
# app/serializers/order_serializer.rb
class OrderSerializer
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
status: @order.status,
total: @order.total_amount,
items: @order.items.map { |item| ItemSerializer.new(item).as_json }
}
end
end
Business Logic Layer in Rails: Service objects implement use cases. Interactors coordinate multiple operations. Domain models contain business rules and validations. Rails developers often create app/services/ or app/interactors/ directories for these classes.
# app/services/orders/create_service.rb
module Orders
class CreateService
def initialize(user)
@user = user
@order_repository = OrderRepository.new
@inventory_service = InventoryService.new
end
def call(order_params)
ActiveRecord::Base.transaction do
validate_inventory(order_params[:items])
order = build_order(order_params)
reserve_inventory(order)
@order_repository.save(order)
Result.new(success: true, order: order)
end
rescue InventoryError => e
Result.new(success: false, errors: [e.message])
end
private
def validate_inventory(items)
items.each do |item|
unless @inventory_service.available?(item[:product_id], item[:quantity])
raise InventoryError, "Insufficient stock for product #{item[:product_id]}"
end
end
end
def build_order(params)
Order.new(
user: @user,
items: params[:items],
shipping_address: params[:shipping_address],
status: 'pending'
)
end
def reserve_inventory(order)
order.items.each do |item|
@inventory_service.reserve(item.product_id, item.quantity)
end
end
end
class Result
attr_reader :order, :errors
def initialize(success:, order: nil, errors: [])
@success = success
@order = order
@errors = errors
end
def success?
@success
end
end
end
Data Access Layer in Rails: Active Record models handle persistence. Repository classes wrap Active Record to provide domain-oriented interfaces. Query objects encapsulate complex queries. Rails defaults to putting persistence logic in models, but explicit repositories improve testability and decouple business logic from OR/M details.
# app/repositories/order_repository.rb
class OrderRepository
def find(id)
order_record = OrderRecord.find(id)
map_to_domain(order_record)
end
def find_pending_orders
OrderRecord.where(status: 'pending').map { |record| map_to_domain(record) }
end
def save(order)
record = OrderRecord.find_or_initialize_by(id: order.id)
record.assign_attributes(
user_id: order.user_id,
status: order.status,
total: order.total_amount
)
record.save!
order.id = record.id
order
end
private
def map_to_domain(record)
Order.new(
id: record.id,
user_id: record.user_id,
status: record.status,
items: record.order_items.map { |item| map_item_to_domain(item) }
)
end
def map_item_to_domain(item_record)
OrderItem.new(
product_id: item_record.product_id,
quantity: item_record.quantity,
price: item_record.price
)
end
end
# app/queries/orders/pending_orders_query.rb
module Orders
class PendingOrdersQuery
def initialize(relation = OrderRecord.all)
@relation = relation
end
def call
@relation
.where(status: 'pending')
.where('created_at > ?', 7.days.ago)
.includes(:user, :order_items)
.order(created_at: :desc)
end
end
end
Organizing Layer Structure: Rails directory structure follows convention. Creating explicit layer directories makes architecture visible. Common approaches include:
# Feature-based organization
app/
features/
orders/
controllers/
services/
repositories/
models/
users/
controllers/
services/
repositories/
models/
# Layer-based organization
app/
controllers/
services/
orders/
users/
repositories/
orders/
users/
models/
# Hybrid approach
app/
controllers/
models/
services/ # Application and domain services
repositories/ # Data access
queries/ # Complex query objects
serializers/ # Response formatting
Dependency Injection in Ruby: Ruby lacks built-in dependency injection containers. Manual injection through constructors provides explicit dependencies. The dry-container and dry-auto_inject gems provide dependency injection frameworks for Ruby applications.
# Manual dependency injection
class OrderService
def initialize(repository: OrderRepository.new,
inventory_service: InventoryService.new,
notifier: EmailNotifier.new)
@repository = repository
@inventory_service = inventory_service
@notifier = notifier
end
end
# Using dry-container
class AppContainer
extend Dry::Container::Mixin
register(:order_repository) { OrderRepository.new }
register(:inventory_service) { InventoryService.new }
register(:email_notifier) { EmailNotifier.new }
register(:order_service) do
OrderService.new(
repository: resolve(:order_repository),
inventory_service: resolve(:inventory_service),
notifier: resolve(:email_notifier)
)
end
end
Practical Examples
E-commerce Order Processing: An online store implements layered architecture to separate HTTP handling, business rules, and data persistence. The presentation layer handles product browsing and cart management. The business logic layer enforces pricing rules, inventory constraints, and order validation. The data access layer manages product catalogs, customer records, and order history.
# Presentation Layer
class CheckoutController < ApplicationController
def create
checkout_service = CheckoutService.new(current_user)
result = checkout_service.process_checkout(
cart_items: params[:items],
shipping_address: params[:shipping_address],
payment_method: params[:payment_method]
)
if result.success?
render json: { order_id: result.order.id }, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
end
# Business Logic Layer
class CheckoutService
def initialize(user)
@user = user
@pricing_service = PricingService.new
@inventory_service = InventoryService.new
@payment_service = PaymentService.new
@order_repository = OrderRepository.new
end
def process_checkout(cart_items:, shipping_address:, payment_method:)
validate_cart_items(cart_items)
ActiveRecord::Base.transaction do
order = build_order(cart_items, shipping_address)
apply_pricing_rules(order)
reserve_inventory(order)
process_payment(order, payment_method)
@order_repository.save(order)
Result.new(success: true, order: order)
end
rescue CheckoutError => e
Result.new(success: false, errors: [e.message])
end
private
def validate_cart_items(items)
raise CheckoutError, "Cart is empty" if items.empty?
items.each do |item|
product = ProductRepository.new.find(item[:product_id])
raise CheckoutError, "Product not available" unless product.available?
end
end
def build_order(items, address)
Order.new(
user: @user,
items: items.map { |i| build_order_item(i) },
shipping_address: address,
status: 'pending'
)
end
def build_order_item(item_data)
product = ProductRepository.new.find(item_data[:product_id])
OrderItem.new(
product_id: product.id,
quantity: item_data[:quantity],
unit_price: product.price
)
end
def apply_pricing_rules(order)
order.subtotal = @pricing_service.calculate_subtotal(order.items)
order.discount = @pricing_service.calculate_discount(order, @user)
order.tax = @pricing_service.calculate_tax(order.subtotal - order.discount)
order.total = order.subtotal - order.discount + order.tax
end
def reserve_inventory(order)
order.items.each do |item|
unless @inventory_service.reserve(item.product_id, item.quantity)
raise CheckoutError, "Failed to reserve inventory"
end
end
end
def process_payment(order, payment_method)
payment_result = @payment_service.charge(
amount: order.total,
method: payment_method,
user: @user
)
unless payment_result.success?
raise CheckoutError, "Payment failed: #{payment_result.error}"
end
order.payment_transaction_id = payment_result.transaction_id
end
end
# Data Access Layer
class OrderRepository
def save(order)
record = OrderRecord.create!(
user_id: order.user.id,
status: order.status,
subtotal: order.subtotal,
discount: order.discount,
tax: order.tax,
total: order.total,
shipping_address: order.shipping_address,
payment_transaction_id: order.payment_transaction_id
)
order.items.each do |item|
OrderItemRecord.create!(
order_id: record.id,
product_id: item.product_id,
quantity: item.quantity,
unit_price: item.unit_price
)
end
order.id = record.id
order
end
def find(id)
record = OrderRecord.includes(:order_items).find(id)
map_to_domain(record)
end
private
def map_to_domain(record)
Order.new(
id: record.id,
user: User.find(record.user_id),
status: record.status,
items: record.order_items.map { |i| map_item_to_domain(i) },
subtotal: record.subtotal,
discount: record.discount,
tax: record.tax,
total: record.total
)
end
end
Multi-Tenant SaaS Application: A project management application serves multiple organizations with isolated data. The presentation layer authenticates users and determines tenant context. The business logic layer enforces tenant isolation and permission rules. The data access layer scopes all queries to the current tenant.
# Presentation Layer - Tenant Context
class ApplicationController < ActionController::API
before_action :authenticate_user!
before_action :set_current_tenant
private
def set_current_tenant
tenant = Tenant.find_by!(subdomain: request.subdomain)
CurrentTenant.set(tenant)
end
end
class ProjectsController < ApplicationController
def index
projects = ProjectService.new.list_projects(current_user)
render json: projects
end
def create
service = ProjectService.new
result = service.create_project(
owner: current_user,
attributes: project_params
)
if result.success?
render json: result.project, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
end
# Business Logic Layer - Tenant Isolation
class ProjectService
def initialize
@project_repository = ProjectRepository.new
@permission_service = PermissionService.new
end
def list_projects(user)
raise UnauthorizedError unless @permission_service.can_view_projects?(user)
@project_repository.find_by_tenant(CurrentTenant.id)
end
def create_project(owner:, attributes:)
raise UnauthorizedError unless @permission_service.can_create_project?(owner)
project = Project.new(
tenant_id: CurrentTenant.id,
owner: owner,
name: attributes[:name],
description: attributes[:description]
)
@project_repository.save(project)
Result.new(success: true, project: project)
rescue ValidationError => e
Result.new(success: false, errors: [e.message])
end
end
# Data Access Layer - Scoped Queries
class ProjectRepository
def find_by_tenant(tenant_id)
ProjectRecord
.where(tenant_id: tenant_id)
.includes(:owner, :members)
.map { |record| map_to_domain(record) }
end
def save(project)
record = ProjectRecord.create!(
tenant_id: project.tenant_id,
owner_id: project.owner.id,
name: project.name,
description: project.description
)
project.id = record.id
project
end
end
# Thread-safe tenant context
class CurrentTenant
def self.set(tenant)
Thread.current[:current_tenant] = tenant
end
def self.id
Thread.current[:current_tenant]&.id or raise "No tenant set"
end
def self.clear
Thread.current[:current_tenant] = nil
end
end
API Gateway with Multiple Backends: A mobile application backend aggregates data from multiple microservices. The presentation layer handles authentication and request routing. The business logic layer coordinates requests to backend services and combines results. The data access layer manages connections to different microservices and databases.
# Presentation Layer
class DashboardController < ApplicationController
def show
dashboard_service = DashboardService.new
result = dashboard_service.get_user_dashboard(current_user)
render json: DashboardSerializer.new(result).as_json
end
end
# Business Logic Layer - Service Coordination
class DashboardService
def initialize
@user_service = UserService.new
@activity_service = ActivityService.new
@analytics_service = AnalyticsService.new
end
def get_user_dashboard(user)
profile = @user_service.get_profile(user.id)
recent_activity = @activity_service.get_recent_activity(user.id, limit: 10)
stats = @analytics_service.get_user_stats(user.id)
Dashboard.new(
profile: profile,
recent_activity: recent_activity,
stats: stats
)
rescue ServiceError => e
# Handle partial failures gracefully
Dashboard.new(
profile: profile,
recent_activity: [],
stats: nil,
errors: [e.message]
)
end
end
# Data Access Layer - Service Clients
class ActivityService
def initialize(http_client: HTTPClient.new)
@client = http_client
@base_url = ENV['ACTIVITY_SERVICE_URL']
end
def get_recent_activity(user_id, limit:)
response = @client.get("#{@base_url}/users/#{user_id}/activity", {
limit: limit
})
parse_activity_response(response)
rescue HTTPError => e
raise ServiceError, "Failed to fetch activity: #{e.message}"
end
private
def parse_activity_response(response)
JSON.parse(response.body)['activities'].map do |item|
Activity.new(
id: item['id'],
type: item['type'],
description: item['description'],
timestamp: Time.parse(item['timestamp'])
)
end
end
end
Reference
Layer Responsibilities
| Layer | Primary Responsibilities | Prohibited Actions |
|---|---|---|
| Presentation | HTTP handling, input validation, response formatting, session management | Business logic, direct database access, domain rules |
| Business Logic | Domain rules, workflow orchestration, transaction management, validation | HTTP details, SQL queries, framework dependencies |
| Data Access | Query construction, OR/M mapping, transaction handling, caching | Business decisions, HTTP handling, view rendering |
| Database | Data storage, query execution, transaction support, indexing | Application logic, validation, formatting |
Common Layer Patterns
| Pattern | Purpose | Implementation |
|---|---|---|
| Repository | Abstract data access | Collection-like interface for domain objects |
| Service Layer | Define application boundaries | Use case classes with transaction control |
| Data Transfer Object | Transfer data between layers | Simple objects with no behavior |
| Facade | Simplify subsystem interface | Single entry point to complex subsystem |
| Unit of Work | Track changes and coordinate writes | Transaction scope with change tracking |
Dependency Direction Rules
| Rule | Description | Example |
|---|---|---|
| Downward Dependencies | Upper layers depend on lower layers | Controllers depend on services |
| No Upward Dependencies | Lower layers never depend on upper layers | Repositories do not reference controllers |
| Interface Segregation | Depend on abstractions, not implementations | Business logic depends on repository interface |
| Acyclic Dependencies | No circular dependencies between layers | Service A cannot depend on Service B if B depends on A |
Ruby Layer Organization
| Approach | Directory Structure | Use Case |
|---|---|---|
| Rails Default | app/controllers, app/models, app/views | Simple applications, rapid prototyping |
| Service Objects | app/services/feature/action_service.rb | Adding business logic layer to Rails |
| Feature Slices | app/features/orders/controllers, services, repositories | Large applications with many features |
| DDD Modules | app/domain, app/application, app/infrastructure | Complex domain models with rich behavior |
Layer Interface Patterns
| Pattern | Input | Output | Error Handling |
|---|---|---|---|
| Command Pattern | Command object with parameters | Result object with success/failure | Return result object with errors |
| Query Pattern | Query parameters as hash | Collection or single object | Raise exception or return nil |
| Result Object | Operation parameters | Result with success flag and payload | Errors in result object |
| Service Method | Primitive types or DTOs | Domain objects or DTOs | Raise domain exceptions |
Testing Strategy by Layer
| Layer | Test Type | Focus | Dependencies |
|---|---|---|---|
| Presentation | Controller tests | HTTP routing, response formatting, authentication | Mock services, stub repositories |
| Business Logic | Service tests | Business rules, workflow logic, validation | Mock repositories, use test doubles |
| Data Access | Repository tests | Query correctness, OR/M mapping | Use test database, seed data |
| Integration | End-to-end tests | Complete flows across all layers | Use test database and real services |