← All posts

May 13, 2026

Inside Gumroad's BundleSearchProductsService: a 45-line tour

A line-by-line read of one of the cleanest service objects in Gumroad's open-source codebase, plus the two changes I'd make.

rails service-objects oss gumroad

I read other people’s code to calibrate my own. Most of what’s in app/services directories on the public internet is either too small to learn from or too tangled to follow. Gumroad’s BundleSearchProductsService sits in the narrow middle: forty-five lines, one job, no inheritance, and a constructor that tells you everything the class needs.

This is a tour of that file. I’ll quote the whole thing, walk through what it’s doing, point at the parts I’d write the same way, and flag the two I’d change.

The whole file

Pinned at SHA 8f6f1c6, MIT-licensed:

# antiwork/gumroad · app/services/bundle_search_products_service.rb · @8f6f1c6
# frozen_string_literal: true

class BundleSearchProductsService
  PER_PAGE = 10

  attr_reader :bundle, :query, :page, :all, :seller

  def initialize(bundle:, seller:, query: nil, page: 1, all: false)
    @bundle = bundle
    @seller = seller
    @query = query
    @page = [page.to_i, 1].max
    @all = all
  end

  def call
    search_params = build_search_params
    product_options = Link.search_options(search_params)
    product_response = Link.search(product_options)
    products = product_response.records.map { BundlePresenter.bundle_product(product: _1) }

    total_count = product_response.results.total
    has_more = !all && (page * PER_PAGE) < total_count

    { products:, has_more:, page:, total_count: }
  end

  private
    def build_search_params
      from = (page - 1) * PER_PAGE
      params = {
        query:,
        from:,
        sort: ProductSortKey::FEATURED,
        user_id: seller.id,
        is_subscription: false,
        is_bundle: false,
        is_alive: true,
        is_call: false,
        exclude_ids: [ObfuscateIds.decrypt(bundle.external_id)],
      }
      params[:size] = all ? 1000 : PER_PAGE
      params
    end
end

That’s the file. Now the tour.

What it does

The class searches for products that can be added to a Gumroad bundle. A bundle is a collection of digital products sold together; the seller is editing the bundle in the dashboard, types into a search box, and the controller calls this service to ask the search index “which of this seller’s products match, excluding the bundle itself, excluding subscriptions and other bundles?”

The result is a hash: the matched products (as presenter dictionaries), a has_more flag for pagination, the current page, and a total count.

The constructor

def initialize(bundle:, seller:, query: nil, page: 1, all: false)
  @bundle = bundle
  @seller = seller
  @query = query
  @page = [page.to_i, 1].max
  @all = all
end

Five keyword arguments, two required and three with sensible defaults. This is the shape I want every service constructor to have. I can read this signature and know exactly what the service needs to do its job: a bundle to exclude, a seller whose products to search, optionally a query string, optionally a page, optionally an “all results” flag.

The [page.to_i, 1].max is the kind of one-liner that’s worth a second look. It’s defensive coercion: convert whatever the controller passed (a string from query params, probably) to an integer, then clamp to a minimum of one. A request with ?page=0 or ?page=-3 or ?page=garbage becomes page = 1 instead of 0, -3, or a NoMethodError deep in the search call. That’s the right place for the sanitization — at the boundary, in the constructor, once.

The attr_reader line is what makes the rest of the class readable. query, page, all, seller, bundle get used as bare references inside #call and #build_search_params, not @query, not params[:query]. Two characters per reference, but across forty lines it adds up.

The #call method

def call
  search_params = build_search_params
  product_options = Link.search_options(search_params)
  product_response = Link.search(product_options)
  products = product_response.records.map { BundlePresenter.bundle_product(product: _1) }

  total_count = product_response.results.total
  has_more = !all && (page * PER_PAGE) < total_count

  { products:, has_more:, page:, total_count: }
end

Three lines of pipeline (build the params, transform them into Elasticsearch options, run the search), one line of mapping (turn the records into presenter hashes), two lines of derived values, and a return. Nine statements. No conditional except the !all && ... clause that decides whether more pages exist.

A few things I want to call out.

The presenter call. BundlePresenter.bundle_product(product: _1) is doing the same job a serializer would do — turning an ActiveRecord object into the shape the React frontend expects. The service doesn’t have to know what fields the frontend wants, and the controller doesn’t have to know how to call the presenter. The boundary is clean: search returns records, the presenter shapes them, the controller renders the result.

The shorthand hash. { products:, has_more:, page:, total_count: } is the Ruby 3.1 hash shorthand syntax — key: with no value is the same as key: key. Once you’ve typed { products: products, has_more: has_more, page: page, total_count: total_count } enough times you stop wanting to. I find this style strictly more readable than the long form once you’ve internalized that the trailing colon means “use the local variable of this name.”

has_more is computed, not queried. Notice the service doesn’t ask Elasticsearch “are there more pages?” — it computes the answer from total_count. That’s the right call. One round-trip to the search cluster, all the pagination math on the Ruby side. The !all short-circuit handles the case where the caller wanted everything in one shot (size: 1000); when all is true, there’s by definition no next page.

The #build_search_params method

private
  def build_search_params
    from = (page - 1) * PER_PAGE
    params = {
      query:,
      from:,
      sort: ProductSortKey::FEATURED,
      user_id: seller.id,
      is_subscription: false,
      is_bundle: false,
      is_alive: true,
      is_call: false,
      exclude_ids: [ObfuscateIds.decrypt(bundle.external_id)],
    }
    params[:size] = all ? 1000 : PER_PAGE
    params
  end

The translation layer. The constructor took human inputs (bundle, seller, query, page); this method emits whatever Elasticsearch needs (from, sort, user_id, four is_* filters, exclude_ids).

The four is_subscription: false, is_bundle: false, is_alive: true, is_call: false lines are domain rules made explicit. A bundle can’t contain subscriptions (those have their own billing model). A bundle can’t contain another bundle (would be confusing for the buyer). Products have to be alive (not soft-deleted). And is_call: false excludes Gumroad’s “calls” product type, which is also not bundle-eligible. Reading this list, I learn the domain.

The ObfuscateIds.decrypt(bundle.external_id) line is doing one thing I consider a smell: the service is reaching into Gumroad’s URL-safe ID encoding to extract a primary key it could have asked the bundle for directly. bundle.id would do the same job. I assume there’s a reason — perhaps bundles in this codebase don’t always have a numeric id available at this layer, or perhaps the search index keys on the obfuscated form. Without more context I’d write exclude_ids: [bundle.id] and see what broke.

What I’d change

Two things, both small.

The size: 1000 ceiling. Setting size = 1000 when all is true is a magic number with no comment explaining why a thousand is the right cap. What happens when a seller has 1,001 products? They get the first thousand and silently lose one. I’d either lift the constant to the top of the file (MAX_PER_REQUEST = 1000), or use the search index’s scroll API for genuinely unbounded fetches. As written, it’s an off-by-one waiting to bite the largest customers — exactly the customers you don’t want it to bite.

The hash return value. A four-key hash is fine for the controller that exists today, but the moment a second caller wants to know whether the search succeeded, errored, or returned empty, the hash starts growing magic keys. I’d wrap the return in a small Result struct (Result = Data.define(:products, :has_more, :page, :total_count)) and let the type tell the story. result.has_more reads better than result[:has_more], and the IDE can autocomplete it.

Neither of these is a bug. They’re the kind of paper cut you address on the third or fourth caller, not the first.

What I’d keep

Everything else.

The class is forty-five lines, has one public method, returns a value, and would be trivial to test (give it a seller with three products, assert the right two come back). It doesn’t inherit from an ApplicationService base class. It doesn’t define a .call class method that hides the constructor. It doesn’t wrap itself in a transaction it doesn’t need. It doesn’t try to be a generic “search anything” abstraction.

When I tell teams what a service object should look like, this is roughly the shape I have in mind. Constructor takes the inputs, one private builder shapes the request, #call runs it and returns the result. The next person to read it can hold the whole thing in their head.

That’s the bar.


Source: bundle_search_products_service.rb — Gumroad MIT, pinned at SHA 8f6f1c6007fe3ab1f1aa9f3b2f643bd32c0ee1da.