A JavaScript developer's guide to Rails: What does Composition Over Inheritance mean?

Rails uses inheritance and mixins heavily to make methods appear throughout your application. This can be confusing for JavaScript developers used to explicit imports. While Rails framework code uses inheritance heavily, thoughtbot recommends your application code prefer composition. This makes your code easier to test and makes dependencies visible.

Why Composition Matters: Testing

The primary benefit of composition is easier testing. You can inject test doubles instead of real objects, making your tests fast, isolated, and free from external dependencies.

With implicit dependencies (inheritance):

# Inheritance example - requires database and complex setup
class WelcomeNotifier < BaseNotifier
  def notify(user)
    send_notification(user, welcome_message)
    log_notification(user)
  end
end

# Test has to deal with the whole inheritance chain
RSpec.describe WelcomeNotifier do
  it "sends welcome notification" do
    user = create(:user)  # Requires database
    notifier = WelcomeNotifier.new

    # Have to mock methods from parent class
    allow(notifier).to receive(:send_notification)
    allow(notifier).to receive(:log_notification)

    notifier.notify(user)

    expect(notifier).to have_received(:send_notification)
  end
end

With explicit dependencies (composition):

# Composition example - dependencies are explicit
class WelcomeNotifier
  def initialize(mailer:, logger:)
    @mailer = mailer
    @logger = logger
  end

  def notify(user)
    @mailer.send(user, welcome_message)
    @logger.log(user, :welcome_sent)
  end

  private

  def welcome_message
    "Welcome to our app!"
  end
end

# Test is simple and fast - no database needed
RSpec.describe WelcomeNotifier do
  it "sends welcome notification" do
    mailer = double("Mailer")
    logger = double("Logger")
    user = double("User")

    notifier = WelcomeNotifier.new(mailer: mailer, logger: logger)

    allow(mailer).to receive(:send)
    allow(logger).to receive(:log)

    notifier.notify(user)

    expect(mailer).to have_received(:send).with(user, "Welcome to our app!")
    expect(logger).to have_received(:log).with(user, :welcome_sent)
  end
end

The composition version uses test doubles instead of real User objects. No database needed. Mailer and logger are fakes, not real services. Tests run in milliseconds, not seconds. You can see exactly what dependencies the class needs, and tests only check WelcomeNotifier logic without touching anything else.

What is Composition?

Composition means building complex functionality by combining simple, independent objects together instead of inheriting functionality from parent classes.

Building with LEGO blocks vs. nested Russian dolls:

  • Inheritance (Russian dolls): Each doll is contained within a larger doll. You inherit everything from your parent, and to understand the smallest doll, you have to open all the outer ones.
  • Composition (LEGO blocks): You snap independent pieces together. Each piece is distinct and clear about its purpose.

In JavaScript terms:

// INHERITANCE - you get everything from the parent
class Animal {
  eat() { }
  sleep() { }
  breathe() { }
}

class Dog extends Animal {
  bark() {
    this.breathe(); // Where's breathe()? Inherited from Animal
  }
}

// COMPOSITION - you explicitly combine pieces
class Dog {
  constructor() {
    this.lungs = new Lungs();      // Clear: Dog HAS lungs
    this.stomach = new Stomach();  // Clear: Dog HAS a stomach
  }

  bark() {
    this.lungs.breathe(); // Clear: calling breathe() on the lungs object
  }
}

With inheritance, Dog IS-A Animal (and gets all Animal’s methods). With composition, Dog HAS-A Lungs and HAS-A Stomach (and uses their methods explicitly).

A Practical Example You Already Know

If you’re coming from Ember, Angular, or NestJS, the inheritance patterns in Rails will feel familiar—those frameworks use similar approaches. This article focuses on modern React/Vue/Svelte developers who are used to explicit composition patterns.

You’ve been using composition in JavaScript:

// COMPOSITION - passing dependencies in
function UserProfile({ apiClient, router }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    apiClient.fetchUser().then(setUser);
  }, []);

  const handleClick = () => {
    router.navigate('/home');
  };

  return <div onClick={handleClick}>{user?.name}</div>;
}

// Usage - you can SEE what this component needs
<UserProfile
  apiClient={myApiClient}
  router={myRouter}
/>

React has its own “magic"—useState tracks state somehow, useEffect manages side effects, and the compiler optimizes things under the hood. But you can see apiClient and router in the props, so you know immediately what this component depends on.

Compare that to if React used inheritance:

// INHERITANCE - methods appear from nowhere
class UserProfile extends MagicalReactComponent {
  componentDidMount() {
    this.fetchUser().then(user => {  // Where's fetchUser()? Magic parent class?
      this.setState({ user });
    });
  }

  handleClick = () => {
    this.navigateTo('/home');  // Where's navigateTo()? Magic parent class?
  }
}

See the difference? With inheritance, you don’t immediately see what dependencies the class needs. Yes, you can find method definitions with "Go to definition” or by searching—but that’s not the point.

The issue is understanding code structure at a glance:

  • With composition: Dependencies are visible in props/constructor. You see { apiClient, router } and immediately know what this needs.
  • With inheritance: Dependencies are hidden in parent classes. You have to hunt through the inheritance chain to understand what’s available.

Rails framework uses inheritance (you’ll have to work with that), but thoughtbot recommends composition for your own code because dependencies are clearer and testing is easier.

The Problem with Deep Inheritance in Your Rails Code

When you build features using inheritance, you recreate the same “where does this come from” problem:

# Implicit dependencies - methods come from parent
class BaseReportGenerator
  def fetch_data
    # ...
  end
end

class UserReportGenerator < BaseReportGenerator
  def generate
    data = fetch_data  # Where's fetch_data? Have to check parent class
    # ...
  end
end

class AdminReportGenerator < BaseReportGenerator
  def generate
    data = fetch_data  # Same problem - not obvious where this comes from
    # ...
  end
end

When reading AdminReportGenerator, you have to hunt through parent classes to find fetch_data.

The Solution: Composition in Rails

Make dependencies explicit by passing them in:

# Explicit dependencies - you can see exactly what's being used
class ReportGenerator
  def initialize(data_fetcher)
    @data_fetcher = data_fetcher  # This object needs a data_fetcher
  end

  def generate
    data = @data_fetcher.fetch  # Calling method on the data_fetcher we passed in
    # ...
  end
end

# Usage - dependencies are visible
user_generator = ReportGenerator.new(UserDataFetcher.new)
admin_generator = ReportGenerator.new(AdminDataFetcher.new)

Real-World Rails Examples

I use “plain Ruby classes” in these examples to mean classes that accept dependencies via their constructor. If you’re from Angular or NestJS, you might call these “services.” In Rails, there’s debate about “Service Objects” as a pattern. I’m simply showing dependency injection and composition here. Call it whatever works for your team.

Example 1: Plain Ruby Classes with Composition

Implicit dependencies (Inheritance):

class BaseNotifier
  def send_notification(user, message)
    # email logic
  end

  def log_notification(user)
    # logging logic
  end
end

class WelcomeNotifier < BaseNotifier
  def notify(user)
    send_notification(user, welcome_message)  # Where's send_notification?
    log_notification(user)                     # Where's log_notification?
  end
end

Explicit dependencies (Composition):

class WelcomeNotifier
  def initialize(mailer:, logger:)
    @mailer = mailer  # Dependencies injected via constructor
    @logger = logger
  end

  def notify(user)
    @mailer.send(user, welcome_message)  # Calling method on injected mailer
    @logger.log(user, :welcome_sent)     # Calling method on injected logger
  end

  private

  def welcome_message
    "Welcome to our app!"
  end
end

# Usage shows all dependencies
notifier = WelcomeNotifier.new(
  mailer: EmailMailer.new,
  logger: NotificationLogger.new
)
notifier.notify(user)

Example 2: Data Processing Pipeline

Implicit dependencies (Inheritance):

class BaseDataProcessor
  def validate_data(data)
    # validation
  end

  def transform_data(data)
    # transformation
  end

  def save_data(data)
    # persistence
  end
end

class UserDataProcessor < BaseDataProcessor
  def process(raw_data)
    validate_data(raw_data)    # Where do these come from?
    transform_data(raw_data)   # Have to check parent
    save_data(raw_data)        # Not obvious
  end
end

Explicit dependencies (Composition):

class DataProcessor
  def initialize(validator:, transformer:, repository:)
    @validator = validator      # Dependencies injected
    @transformer = transformer
    @repository = repository
  end

  def process(raw_data)
    @validator.validate(raw_data)              # Calling method on injected validator
    transformed = @transformer.transform(raw_data)
    @repository.save(transformed)
  end
end

# Create specialized processors by composing different components
user_processor = DataProcessor.new(
  validator: UserValidator.new,
  transformer: UserTransformer.new,
  repository: UserRepository.new
)

admin_processor = DataProcessor.new(
  validator: AdminValidator.new,
  transformer: AdminTransformer.new,
  repository: AdminRepository.new
)

Example 3: Authentication With Composition

A BaseController between ApplicationController and your feature controllers is a valid Rails pattern that many apps use successfully. This example shows an alternative approach using composition, not a critique of BaseController.

Implicit dependencies (Additional inheritance layer):

class BaseController < ApplicationController
  def current_user
    # auth logic
  end

  def authorize!
    # authorization logic
  end
end

class PostsController < BaseController
  def create
    authorize!  # Where's this from? BaseController? ApplicationController?
    # ...
  end
end

Explicit dependencies (Composition):

class PostsController < ApplicationController
  before_action :setup_auth_service

  def create
    @auth_service.authorize!(current_user, :create_post)  # Calling method on injected service
    # ...
  end

  private

  def setup_auth_service
    @auth_service = AuthenticationService.new  # Dependency created in setup
  end

  def current_user
    @auth_service.current_user(session)
  end
end

Benefits of Composition

Composition makes testing dramatically easier. Inject test doubles instead of real objects, and tests run in milliseconds without databases or external services. Dependencies are clear from the initialize method. You can search for class names and find all uses in your codebase. Swapping implementations doesn’t require changing inheritance hierarchies. Methods are either defined in the class or called on an injected dependency, so there’s no mystery about where they come from.

When to Use Inheritance vs. Composition

Use Inheritance when:

  • Working with Rails framework classes (ApplicationController, ApplicationRecord)
  • Working with Rails-ecosystem libraries that expect inheritance (Pundit policies, ViewComponents, Interactors, notification gems, etc.)
  • Creating genuine “is-a” relationships (an Admin IS-A User)
  • The conventions of the framework/library expect it

Prefer Composition for:

  • Building your own business logic
  • Making dependencies explicit and visible
  • Writing code that’s easier to test in isolation
  • When you need flexibility to swap implementations

Key insight for JS developers: You’ll use inheritance when working with Rails and its ecosystem (you don’t have a choice). Prefer composition for your own application code because it’s closer to what you’re used to in modern JavaScript and makes testing easier.

Why This Matters for Rails

You can’t change Rails framework code - you’ll always have to deal with methods appearing from ApplicationController or ApplicationRecord. That’s just how Rails works.

But you CAN control your application code. When building your own features, use composition to make dependencies visible. Your tests will be faster, your code will be clearer, and you won’t have to trace through inheritance chains to understand what a class needs.

The Key Takeaway

Rails framework uses inheritance and mixins, so you have to learn where things come from. Your application code can use composition to make dependencies explicit. You only fight with “where does this come from” when dealing with Rails itself, not your own code.

This is a core thoughtbot principle: embrace Rails conventions for framework code, but write clear, explicit application code.

Practical Tips for Applying Composition

Inject dependencies in initialize to make what your class needs explicit. Use keyword arguments like initialize(mailer:, logger:) to make dependencies clear. Keep classes small. If you need many dependencies, your class might be doing too much. Extract business logic into plain Ruby classes that use composition. Test with doubles since composition makes it easy to inject test doubles for fast tests.

Conclusion

You already know composition from JavaScript - React props, dependency injection, functional programming. Rails code works the same way when you choose composition over inheritance chains.

Start with simple classes that take dependencies in their constructor. Test them with doubles. When you read code six months later, you’ll see exactly what each class needs without tracing through parent classes.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.