Oct 27, 2025

Layered Architecture in Rails: Basics

Dariusz Michalski

CEO

Learn how layered architecture in Rails enhances application maintainability, scalability, and team collaboration by clearly defining responsibilities.

Layered architecture in Rails is a method of organizing your application into clear, distinct layers, each handling specific responsibilities. This approach simplifies development, especially for complex projects, by creating structured boundaries between the following layers:

  • Presentation Layer: Manages user interactions (e.g., views, templates, or frontend frameworks like React).

  • Application Layer: Orchestrates workflows (e.g., controllers and service objects).

  • Domain Layer: Contains core business logic (e.g., rules for VAT calculations in Switzerland).

  • Infrastructure Layer: Handles data storage and external systems (e.g., ActiveRecord, APIs, or background jobs).

This structure prevents "fat" models and controllers, reduces code coupling, and improves maintainability, scalability, and testing. For example, in a Swiss e-commerce app, VAT rules would live in the domain layer, separate from how data is stored or displayed. This ensures that changes to tax laws or payment systems don’t disrupt the rest of the codebase.

Key benefits:

  • Easier maintenance: Changes in one layer don’t affect others.

  • Better testing: Isolated layers make unit and integration testing simpler.

  • Team collaboration: Developers can work on different layers without conflicts.

By aligning Rails components (like controllers, models, and views) with these layers and using tools like service objects, query objects, and presenters, you can keep your app organized and efficient.

Example directory structure for a layered Rails app:

app/
├── controllers/          # Application Layer
├── models/               # Domain Layer
├── services/             # Application/Domain Layer
├── queries/              # Infrastructure Layer
├── views/                # Presentation Layer

This setup is especially useful for Swiss businesses managing complex requirements like multi-currency support (CHF) or regulatory compliance. It ensures your app remains clean and scalable as it grows.

Layered Rails Design with Vladimir Dementyev

Rails

Main Layers in a Rails Application

When you map Domain-Driven Design (DDD) principles to a Rails application, you create a well-structured and maintainable setup. Each layer has its own responsibilities, and Rails provides specific tools for each. These layers build upon one another, ensuring clarity and ease of maintenance.

Presentation Layer

The Presentation Layer is all about what users see and interact with. In Rails, this includes views, templates, and any frontend tools or frameworks you use.

Traditional Rails views rely on .html.erb templates, partials, and layouts to generate HTML. For example, formatting data like CHF prices in a Swiss-friendly format (e.g., 1'234.56 CHF) is handled here.

In modern setups, Rails often works with frontend frameworks like React or Vue.js for creating dynamic and interactive user interfaces. In these cases, the frontend sends HTTP requests to Rails controllers and receives JSON responses. The frontend framework then handles rendering and user interactions, while Rails focuses on processing and delivering the data. This separation keeps your presentation layer flexible without tangling it with core logic.

Application Layer

The Application Layer acts as the bridge between the user interface and the core business logic. It doesn’t contain the business rules itself but ensures everything flows smoothly.

In Rails, the controllers are the main part of this layer. They handle user requests but delegate any complex processes to service objects. For instance, a service object like OrderProcessingService could handle tasks such as checking inventory, processing payments, and sending confirmation emails - all without embedding the business rules for these tasks.

This layer also manages authentication, authorisation, and request formatting. It ensures users can only access what they’re allowed to and that data moves correctly between the presentation and domain layers.

Domain Layer

The Domain Layer is the heart of your application. It houses the core business logic and rules, modelling real-world processes and enforcing constraints.

While Rails’ ActiveRecord can store some domain logic, more complex rules often live in plain Ruby objects or modules. Keeping these rules separate from persistence ensures they remain focused and adaptable.

For example, in a Swiss e-commerce application, the domain layer might include rules for VAT calculations, shipping restrictions, or compliance with local consumer protection laws. These rules operate independently of how data is stored or displayed.

This independence is key. Business logic in this layer shouldn’t depend on whether you’re using PostgreSQL, MySQL, or any other technology. It also shouldn’t matter if the user interface is a web app or a mobile app. This separation makes the core logic more stable and easier to test.

Since business requirements evolve over time, most changes occur in this layer. A clear separation ensures these updates don’t disrupt the entire application.

Infrastructure Layer

The Infrastructure Layer is where your application interacts with external systems like databases, APIs, file storage, and background jobs. It forms the technical foundation of your app.

In Rails, ActiveRecord is the most prominent infrastructure component. It manages database connections, generates queries, and handles data persistence. Models inherit from ActiveRecord::Base, giving them database capabilities while still being tied to domain logic.

Background job processors, such as Sidekiq, DelayedJob, or ActiveJob, handle asynchronous tasks like sending emails, processing payments, or generating reports. These tools ensure the app stays responsive by offloading time-consuming tasks.

This layer also includes external service integrations. Whether you’re connecting to payment gateways, email providers, or Swiss banking APIs, these interactions happen here. The infrastructure layer provides data and services to the domain layer without exposing technical details.

Layer

Primary Role

Rails Components

Example Use

Presentation

User interaction & display

Views, helpers, React/Vue.js

Formatting CHF prices, rendering forms

Application

Request coordination

Controllers, service objects

Processing orders, user authentication

Domain

Business logic & rules

Models, Ruby objects

VAT calculations, inventory management

Infrastructure

External system integration

ActiveRecord, Sidekiq, API clients

Database queries, background jobs, payment processing

The success of a layered architecture lies in maintaining proper dependencies. Each layer should only rely on the ones below it. For example, the presentation layer communicates with the application layer, which calls domain logic, which in turn interacts with the infrastructure. This approach keeps your code clean, stable, and easy to maintain.

Benefits of Layered Architecture

When you structure your Rails application with clearly defined layers, the advantages quickly become apparent. This approach influences how your team operates, how your application performs, and how you respond to evolving requirements.

Improved Maintainability and Scalability

One of the biggest perks of layered architecture is separation of concerns, which makes maintaining your application far easier. Each layer has a specific role, so you can tweak one part of your app without causing unintended side effects elsewhere. For instance, if you need to adjust your database schema, those changes stay within the infrastructure layer, leaving your domain logic, controllers, and views untouched.

This separation is especially useful for Swiss-specific needs. Imagine your e-commerce app has to update VAT calculations due to new Swiss tax rules. With a layered setup, these tax rules are neatly housed in the domain layer, independent of how data is stored or displayed.

Scalability is another standout benefit. Each layer can grow at its own pace. For example, during peak shopping seasons, you might need to scale your API layer to manage additional traffic without touching your database layer. This horizontal scaling lets you add application servers as needed, avoiding a complete system overhaul.

The infrastructure layer also benefits from this flexibility. You can introduce caching, add read replicas, or deploy background job processors without disrupting your core logic. This adaptability is crucial for Swiss businesses that deal with seasonal traffic spikes or must comply with local data residency laws.

These structural advantages naturally lead to smoother testing workflows.

Simplified Testing

Testing becomes a lot easier when responsibilities are neatly divided. Unit tests are more focused because business logic resides in isolated service objects. For instance, testing payment processing doesn’t require setting up databases, controllers, or views.

Take a service object that calculates shipping costs for Swiss addresses. In a layered system, this logic is separate from ActiveRecord models or controllers. Your tests can zero in on the rules - testing postal codes, weight thresholds, and delivery options - without needing database fixtures or HTTP setups.

Integration testing also benefits. You can test how layers interact without involving the entire application. For example, validating the connection between a controller and a service object doesn’t require rendering views or hitting external APIs.

This reduced complexity makes it easier for new team members to understand and write tests. They can focus on testing specific layers without getting bogged down by interconnected dependencies. The result? Better test coverage and greater confidence when deploying updates.

By isolating concerns, you can also use mocking and stubbing effectively, speeding up test execution and eliminating reliance on external systems.

Enhanced Team Collaboration

Layered architecture doesn’t just improve the code - it also boosts how your team works together. It supports parallel development, allowing team members to work independently. For example, frontend developers can build React components while backend developers focus on service objects and APIs. As long as the interfaces stay consistent, both teams can move forward without stepping on each other’s toes.

For distributed teams, clear boundaries reduce conflicts and simplify workflows. A developer in Zurich can work on payment integration while a colleague in Geneva refines the user interface. These boundaries minimise merge conflicts and streamline collaboration.

Code ownership becomes more defined, too. Team members can specialise in specific layers - some focusing on domain logic, others on infrastructure or performance tuning. This specialisation leads to deeper expertise and higher-quality code.

Onboarding also gets easier. New developers don’t need to grasp the entire application at once. They can start with a single layer, like the presentation layer, and expand their understanding over time. For example, a new hire might begin by working on UI tasks before diving into domain logic.

Clear layer responsibilities also improve documentation and knowledge sharing. Team members can create focused documentation for their layer, making it easier to onboard new developers and share knowledge about specific features.

To sum it up, layered architecture transforms development workflows in several key ways:

Benefit Category

Key Advantage

Impact on Development

Maintainability

Isolated changes

Updates stay contained within layers

Scalability

Independent scaling

Resources are allocated more effectively

Testing

Clear test boundaries

Faster, more reliable test processes

Collaboration

Parallel workflows

Fewer conflicts, quicker progress

These benefits create an environment where teams can deliver features faster and with greater confidence. For Swiss businesses that demand reliable, scalable, and compliant applications, this approach not only reduces risks but also lowers development costs, all while maintaining high-quality standards.

How to Implement Layered Architecture in Rails

Introducing layered architecture into your Rails application can help maintain a clean and organised codebase. But it requires a methodical approach to avoid unnecessary disruptions.

Setting Up Directory Structure

Start by restructuring your app/ folder to reflect the responsibilities of each architectural layer. Here's a suggested layout:

app/
├── controllers/          # Application Layer
├── models/               # Domain Layer
├── views/                # Presentation Layer
├── services/             # Application/Domain Layer
├── queries/              # Infrastructure Layer
├── forms/                # Application Layer
├── presenters/           # Presentation Layer
├── adapters/             # Infrastructure Layer
└── jobs/                 # Infrastructure Layer

For a Swiss e-commerce platform, the structure might look like this:

app/
├── services/
├── payment/
├── swiss_payment_service.rb
└── vat_calculator_service.rb
└── shipping/
└── swiss_post_service.rb
├── queries/
├── product_query.rb
└── order_query.rb
├── forms/
├── checkout_form.rb
└── address_form.rb
└── adapters/
    ├── swiss_post_adapter.rb
    └── payment_gateway_adapter.rb

This structure ensures developers can quickly locate the relevant files. For instance, if you need to tweak VAT calculations, you know to check app/services/payment/vat_calculator_service.rb instead of digging through unrelated files.

Naming conventions are equally important. Use descriptive names that reflect both the layer and the business context. For example, a service for validating Swiss postal codes should be called SwissPostalCodeValidationService, not something vague like ValidationService.

Once your directory structure is set, assign each Rails component to its appropriate layer.

Placing Rails Components in Layers

Each component in Rails has a specific role within the layered architecture. Here's how they align:

Rails Component

Target Layer

Responsibility

Example

Controllers

Application Layer

Handle requests and validate inputs

OrdersController

Models (ActiveRecord)

Domain/Repository Layer

Manage business rules and persistence

Order model with pricing logic

Views & Partials

Presentation Layer

Render data and manage user interaction

orders/show.html.erb

Service Objects

Application/Domain Layer

Execute complex business processes

OrderProcessingService

Background Jobs

Infrastructure Layer

Manage asynchronous tasks

EmailDeliveryJob

Controllers should remain lean, focusing on routing and delegating tasks to service objects. For example:

class OrdersController < ApplicationController
  def create
    result = OrderProcessingService.new(order_params, current_user).call

    if result.success?
      redirect_to order_path(result.order)
    else
      render :new, alert: result.error_message
    end
  end
end

Keep domain logic, like calculating Swiss VAT, in your models. However, complex workflows - such as processing an entire order - belong in service objects. For instance, an OrderProcessingService might coordinate multiple tasks like charging a payment, updating the order status, and scheduling shipping.

Background jobs, such as creating shipping labels with Swiss Post, fit into the infrastructure layer since they handle external communications and asynchronous tasks.

Managing Communication Between Layers

To maintain a clean architecture, ensure layers interact only through defined interfaces. Communication should flow downward - each layer can access the ones below it, but not the other way around. For example, a controller can call a service object, but a model shouldn’t interact directly with a view.

Service objects act as intermediaries between layers. Instead of controllers directly handling multiple models, they delegate to a single service:

class SwissOrderProcessingService
  def initialize(order_params, user)
    @order_params = order_params
    @user = user
  end

  def call
    return failure("Invalid address") unless valid_swiss_address?

    order = create_order
    payment_result = process_payment(order)

    return failure(payment_result.error) unless payment_result.success?

    schedule_shipping(order)
    send_confirmation_email(order)

    success(order)
  end

  private

  def valid_swiss_address?
    SwissAddressValidator.new(@order_params[:shipping_address]).valid?
  end

  def process_payment(order)
    SwissPaymentService.new(order).process
  end
end

Data transfer objects (DTOs) simplify communication between layers by passing only the necessary data. For instance, instead of exposing an ActiveRecord object to the presentation layer, use a presenter:

class OrderPresenter
  def initialize(order)
    @order = order
  end

  def formatted_total
    "CHF #{@order.total_in_cents / 100.0}"
  end

  def swiss_formatted_date
    @order.created_at.strftime("%d.%m.%Y")
  end
end

Event-driven communication is another way to decouple layers. Instead of directly invoking notification services, publish events when specific actions occur:

class OrderProcessingService
  def call
    # ... process order ...

    if order.completed?
      publish_event('order.completed', order_id: order.id)
    end
  end
end

This approach allows you to introduce new behaviours - like analytics tracking or updating inventory - without altering existing code.

Finally, use dependency injection to manage external dependencies. For example, instead of hardcoding a payment gateway, pass it as a parameter:

class OrderProcessingService
  def initialize(order_params, user, payment_gateway: SwissPaymentGateway.new)
    @order_params = order_params
    @user = user
    @payment_gateway = payment_gateway
  end
end

This makes the code easier to test and allows you to switch implementations without rewriting core logic.

Common Patterns for Managing Complexity

As your Rails application grows, managing complexity becomes crucial. Certain patterns naturally emerge, helping you maintain a clear separation of concerns and support a layered architecture. These patterns act as essential tools to keep your codebase organised and efficient.

Service Objects

Service objects are designed to handle business logic outside of controllers and models. They take on the heavy lifting of your application layer, managing operations that don't neatly fit into ActiveRecord models or controllers.

For example, instead of cramming payment processing, inventory updates, and email notifications into a single controller action, you can delegate these tasks to a service class.

Here’s an example of a service object tailored for a Swiss e-commerce platform:

class SwissOrderFulfillmentService
  def initialize(order, shipping_method)
    @order = order
    @shipping_method = shipping_method
  end

  def call
    return failure("Order already processed") if @order.processed?

    ActiveRecord::Base.transaction do
      update_inventory
      calculate_swiss_vat
      create_shipping_label
      send_customer_notification
    end

    success(@order)
  rescue StandardError => e
    failure("Processing failed: #{e.message}")
  end

  private

  def calculate_swiss_vat
    vat_rate = 0.077
    @order.update!(vat_amount: @order.subtotal * vat_rate)
  end

  def create_shipping_label
    SwissPostAdapter.new(@order, @shipping_method).generate_label
  end
end

Service objects shine when coordinating multiple models or external services, ensuring each component remains focused on its specific role. They also make testing easier by isolating business logic from the database and view layers.

Query Objects

Query objects simplify and centralise complex database queries, keeping them separate from models. This improves code maintainability and makes it easier to adapt queries as requirements evolve.

Imagine a Swiss retail application that needs to search for products based on various criteria:

class SwissProductSearchQuery
  def initialize(params = {})
    @params = params
  end

  def call
    products = Product.includes(:category, :brand)
    products = filter_by_price_range(products)
    products = filter_by_swiss_availability(products)
    products = filter_by_language(products)
    products
  end

  private

  def filter_by_price_range(products)
    return products unless @params[:min_price] || @params[:max_price]

    products = products.where("price_chf >= ?", @params[:min_price]) if @params[:min_price]
    products = products.where("price_chf <= ?", @params[:max_price]) if @params[:max_price]
    products
  end

  def filter_by_swiss_availability(products)
    return products unless @params[:swiss_only]

    products.joins(:shipping_zones)
            .where(shipping_zones: { country_code: 'CH' })
  end

  def filter_by_language(products)
    return products unless @params[:language]

    products.joins(:translations)
            .where(translations: { locale: @params[:language] })

This query object efficiently handles multi-criteria searches while addressing Swiss-specific needs, such as filtering by availability in Switzerland. By centralising query logic, you can reuse and test it independently, making it easier to optimise for performance or adapt as requirements change.

Form Objects and Presenters

Form objects and presenters provide additional ways to separate concerns. Form objects manage input handling and validation, while presenters handle view-specific logic, keeping controllers and templates clean.

Form Objects
These are especially helpful for forms that involve multiple models or custom validation rules. For instance, a Swiss customer registration form might validate postal codes, manage multiple addresses, and create both user and billing records:

class SwissCustomerRegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :company_name, :string
  attribute :vat_number, :string
  attribute :billing_address, :string
  attribute :postal_code, :string
  attribute :city, :string

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :postal_code, format: { with: /\A\d{4}\z/, message: "must be a valid Swiss postal code" }
  validates :vat_number, format: { with: /\ACHE-\d{9}/, message: "must be a valid Swiss VAT number" }, allow_blank: true

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      user = create_user
      create_billing_address(user)
      send_welcome_email(user)
    end

    true
  rescue StandardError
    false
  end

  private

  def create_user
    User.create!(
      email: email,
      password: password,
      company_name: company_name,
      vat_number: vat_number
    )
  end
end

Presenters
Presenters format data for display, ensuring templates stay focused on layout rather than logic. For example, a Swiss order presenter could format currencies, dates, and shipping details according to local conventions:

class SwissOrderPresenter
  def initialize(order)
    @order = order
  end

  def formatted_total
    "CHF #{sprintf('%.2f', @order.total_chf)}"
  end

  def formatted_order_date
    @order.created_at.strftime("%d.%m.%Y")
  end

  def shipping_status_badge
    case @order.shipping_status
    when 'pending'
      { text: 'Ausstehend', class: 'badge-warning' }
    when 'shipped'
      { text: 'Versendet', class: 'badge-success' }
    when 'delivered'
      { text: 'Zugestellt', class: 'badge-info' }
    end
  end

  def swiss_post_tracking_url
    return nil unless @order.tracking_number

    "https://service.post.ch/ekp-web/track/#{@order.tracking_number}"
  end
end

Conclusion

Layered architecture brings a fresh perspective to Rails development by establishing clear divisions between responsibilities. By separating the presentation, application, domain, and infrastructure layers, this structure ensures that each part of the system can function and grow independently without stepping on each other's toes.

These principles align perfectly with the patterns and directory structures discussed earlier. When responsibilities are well-isolated, updates - like changing databases or revamping user interfaces - become much smoother, reducing the risk of disruptions. This separation also makes it easier for teams to work in parallel, simplifies testing processes, and allows different layers to scale independently, so resources can be allocated where they’re needed most.

Maintaining strict layer dependencies is key - each layer should only rely on those beneath it. Tools such as service objects, query objects, and form objects help enforce these boundaries, keeping your application organised as it grows in size and complexity.

For Swiss businesses looking to build scalable and maintainable Rails applications, USEO offers expert Ruby and modern web development services. Their team specialises in creating layered applications designed to adapt to evolving requirements and challenges.

FAQs

How does using layered architecture in Ruby on Rails enhance an application's maintainability and scalability?

Layered architecture in Ruby on Rails helps keep your application organised by dividing it into clear layers like controllers, models, and views. Each of these layers has its own job, making the codebase easier to navigate, troubleshoot, and update when needed. By keeping responsibilities separate, it’s less likely that changes in one part of the app will accidentally cause problems elsewhere.

When it comes to scalability, this structure shines. It separates business logic, data handling, and user interface components, meaning you can scale specific parts of the app as demand grows without disrupting the rest. This approach not only boosts efficiency but also helps optimise performance and manage resources effectively.

What distinguishes the application layer from the domain layer in a Ruby on Rails project?

The application layer and domain layer in a Ruby on Rails project play distinct roles, helping to keep the structure organised and easy to manage.

The application layer is all about handling user interactions, processing requests, and managing workflows. Think of it as the middleman that connects the user interface with the deeper, core logic of your application.

In contrast, the domain layer is where the business rules and core logic live. It’s responsible for defining how data is handled and ensuring that everything aligns with the business requirements. Separating these layers makes your application easier to maintain and expand, with each part focusing on its specific job.

What are service objects and query objects, and how can they be used effectively in a layered Rails architecture?

Service objects and query objects are valuable tools in a layered Rails architecture, helping to keep your code organised, modular, and easier to manage.

Service objects are designed to handle business logic that doesn’t fit neatly into a model or controller. Think of tasks like processing payments or managing user onboarding - these can be offloaded to service objects, allowing your controllers to stay streamlined and focused on their primary responsibilities.

On the other hand, query objects come into play when dealing with complex database queries that would otherwise clutter your models. By using query objects, you keep your models clean while still supporting reusable and well-structured database interactions.

This separation of concerns not only improves code readability but also reduces redundancy, making your application simpler to scale and test effectively.

Related Blog Posts

Have a project idea? Let's talk and bring it to life

Your highly qualified specialists are here. Get in touch to see what we can do together.

Dariusz Michalski

Dariusz Michalski, CEO

dariusz@useo.pl

Konrad Pochodaj

Konrad Pochodaj, CGO

konrad@useo.pl

Have a project idea? Let's talk and bring it to life

Your highly qualified specialists are here. Get in touch to see what we can do together.

Dariusz Michalski

Dariusz Michalski, CEO

dariusz@useo.pl

Konrad Pochodaj

Konrad Pochodaj, CGO

konrad@useo.pl

Have a project idea? Let's talk and bring it to life

Your highly qualified specialists are here. Get in touch to see what we can do together.

Start a Project
our Office

ul. Ofiar Oświęcimskich 17

50-069 Wrocław, Poland

©2009 - 2025 Useo sp. z o.o.

Start a Project
our Office

ul. Ofiar Oświęcimskich 17

50-069 Wrocław, Poland

©2009 - 2025 Useo sp. z o.o.