Untangling the Rails Monolith - quick look at the code

Published: (December 11, 2025 at 05:05 PM EST)
3 min read
Source: Dev.to

Source: Dev.to

Data Separation at the Codebase Level

Last time I wrote about data separation at the database level. As we already know, each component should work as an (almost) independent service. How can that be achieved at the codebase level? What should we do when, for example, the Accounting component needs address data for an Employee, or when the Checkout component needs the Organization subscription plan?

Each of those components should ideally live as a separate app. Imagining them as standalone applications makes it easier to design future code without coupling services to each other.

As in the previous post, let’s focus on a kind‑of real‑life example.

Marketplace Example

Imagine a simple app where a User can:

  • Register an account
  • Select a subscription plan
  • Post ads for their goods
  • Buy goods from other users

Depending on the subscription type, we offer better shipping options, discounts, etc.

Component Layout

app/
├─ components/
│  ├─ users/
│  ├─ marketplace/
│  ├─ payments/
│  └─ subscriptions/

Each component has its own controllers/, models/, services/, etc. In more complex apps you might also see infrastructure/, domain/, repositories/, and others, but we’ll keep the example simple.

In an ideal world, each component lives on its own: the Users component knows only about the Users model (and related models), handling login, password updates, account data, etc.

When a user reaches the Marketplace and wants to place an order, the Marketplace component needs to know:

  • Where the order should be shipped
  • Who the recipient is
  • What discounts (if any) apply

Bad Approach: Direct Model Coupling

# app/components/marketplace/controllers/orders_controller.rb
def create
  @order = Marketplace::Models::Order.new(
    user_id:          current_user.id,
    shipping_address: current_user.shipping_address,
    offering:         current_user.subscription_plan,
    discounts:        current_user.discounts,
    items:            order_params[:items_ids]
  )
  # …
end

Coupling the Marketplace component directly to the Users model is fragile. If the user.discounts scope changes, the endpoint’s behavior changes silently, making debugging difficult.

Better Approach: Public API / Facade

Expose a stable public API from the Users component that other components can consume.

# app/components/users/public/api.rb
class << self
  UserResponse = Data.define(:shipping_address, :subscription_plan, :discounts)

  def fetch_user_data(user_id:)
    user = Users::Models::User.find_by(user_id: user_id)
    UserResponse.new(
      shipping_address:  user.shipping_address,
      subscription_plan: user.subscription_plan,
      discounts:          user.discounts.active.sum(:discount_amount)
    )
  end
end

Now the Marketplace controller uses that API:

# app/components/marketplace/controllers/orders_controller.rb
def create
  user_data = ::Users::Public::Api.fetch_user_data(user_id: current_user.id)

  order = Marketplace::Models::Order.new(
    user_id:          current_user.id,
    shipping_address: user_data.shipping_address,
    offering:         user_data.subscription_plan,
    discounts:        user_data.discounts,
    items:            order_params[:items_ids]
  )
  # …
end

All user.shipping_address accesses now happen inside the users/ component. Other components retrieve data only through Users::Public::Api. This makes the API the single source of truth; if the underlying data model changes, only the API needs to be updated, and consumers remain unaffected.

If the Users component wants to change how discounts are calculated, it can version the API or coordinate changes with consuming components, preserving backward compatibility.

Why It Matters

Directly calling ActiveRecord models across domain boundaries creates strong coupling. Consumers often need only a few columns, not an entire User record. If those columns move to a different table, direct calls break (user.shipping_address no longer exists), forcing a time‑consuming debug.

Using a component’s public API abstracts the data source. Whether the address lives in the original users table or a new one is irrelevant to the consumer; the API guarantees the same contract. This reduces the need for consumers to know internal implementation details and protects the system from cascading changes.

Back to Blog

Related posts

Read more »

Surface Tension of Software

Article URL: https://iamstelios.com/blog/surface-tension-of-software/ Comments URL: https://news.ycombinator.com/item?id=46261739 Points: 41 Comments: 11...