← Back to portfolio

Engineering Notes

Building Iron Cars Shop: what actually goes into an AI-powered marketplace

A breakdown of the architecture decisions behind a solo-built vehicle marketplace — where I applied eager loading, where I deliberately didn't, and why an AI agent should never be trusted to write to your database on its own.

View the repository on GitHub
Thiago H. R. CostaThiago H. R. Costa·9 min readRuby on RailsPostgreSQLAI AgentsStripe
Iron Cars Shop — vehicle marketplace covering the USA and Canada
N+1 → 1
query per catalog page via selective eager loading
ID-first
pagination avoids full-row LIMIT/OFFSET scans
SHA1
filter fingerprint caches expensive aggregate counts
Schema-gated
AI writes only through app-validated business rules

Most marketplace side projects stop at "list some items, let people click on them." I wanted Iron Cars Shop to actually behave like a marketplace under load — a catalog that stays fast as inventory grows, leads that get qualified and routed automatically, and a negotiation flow that doesn't fall apart the moment two people need to go back and forth on price.

This post is the engineering walkthrough I wish more portfolio projects came with: not just what the app does, but the specific trade-offs behind how it's built.

Iron Cars Shop negotiation CRM — buyer and seller chat with offer history
The negotiation CRM: offers, counter-offers, and status all tracked per conversation.

01The problem with catalogs at scale

The obvious first move on a listings page is Vehicle.includes(:vehicle_features, car_model: :brand, photos_attachments: :blob) everywhere, all the time. And for the actual page render, that's correct — it collapses what would otherwise be one query per row into a handful of batched queries.

But I didn't apply that blindly. In the same controller, the query that counts total results for pagination intentionally skips eager loading:

app/controllers/cars_controller.rb# We build the base query. We DO NOT use .includes() here because it causes
# massive memory overhead for .count and forces implicit cross-joins when
# we .pluck(:id) later.
@vehicles_query = Vehicle.published_on_marketplace

That distinction matters more than the eager loading itself. A count query only needs to know how manyrows match — pulling in every association just to discard them is wasted memory and wasted round-trips. Knowing when a pattern helps and when it actively hurts is the part that doesn't show up in tutorials.

02Pagination that doesn't degrade as the table grows

Standard LIMIT / OFFSET pagination has a well-known failure mode: the database still has to walk through and discard every row before the offset, which gets slower as the table grows and the user pages deeper.

I split it into two steps instead — fetch the page's primary keys first, using the index, then hydrate only those specific records with the full set of associations:

the deferred-join patternvehicle_ids = @vehicles_query
  .order("vehicles.id DESC")
  .offset(offset_value)
  .limit(@vehicles_per_page)
  .pluck("vehicles.id")

@vehicles = Vehicle.published_on_marketplace
  .includes(:vehicle_features, car_model: :brand, photos_attachments: :blob)
  .where(id: vehicle_ids)
  .sort_by { |v| vehicle_ids.index(v.id) } # restore original order

The first query only ever touches the primary key index — it never has to load a row's full contents just to skip past it. The second query does the expensive hydration, but only for the exact nine rows this page actually needs.

03Caching that respects the shape of the query, not just the URL

Vehicle counts and category breakdowns require JOINs and GROUP BY across the whole catalog — expensive to run on every page load, especially with filters applied. I cache those results under a fingerprint of the active filters:

app/controllers/cars_controller.rbcache_fingerprint = Digest::SHA1.hexdigest(
  request.query_parameters.except(:page, :cursor).to_json
)

@total_vehicles_count = Rails.cache.fetch("vc_count_#{cache_fingerprint}", expires_in: 5.minutes) do
  @vehicles_query.count
end

Excluding page and cursor from the fingerprint is deliberate — two users paginating through the same filtered search share the same cached count, instead of each page number generating its own cache entry for no reason.

Trade-off, stated plainly:this means counts can be up to five minutes stale during high-traffic filtering. For a marketplace, that's an acceptable trade against hitting Postgres with a full aggregate scan on every keystroke of a filter form.

04An AI agent that qualifies leads — without writing to the database on its own

The landing page has a conversational assistant that talks to visitors, figures out what car they're looking for, and collects their contact info. It's built on OpenAI's Responses API with a strict JSON Schema — the model has to return exactly the fields I define, every turn:

app/services/agent_lead_service.rbRESPONSE_SCHEMA = {
  type: "object",
  additionalProperties: false,
  required: %w[assistant_message name email phone interested_in should_create_lead],
  properties: {
    assistant_message: { type: "string" },
    name:  { type: %w[string null] },
    email: { type: %w[string null] },
    should_create_lead: { type: "boolean" }
    # ...
  }
}.freeze

The part I care most about here isn't the schema itself — it's what happens after. The model can only flag that it thinks a lead is ready via should_create_lead. The actual database write is gated by my own application logic, which checks that the required fields are genuinely present and that the visitor gave an explicit closing confirmation:

app/services/agent_lead_service.rbdef maybe_create_lead!(payload:, collected:)
  return unless required_fields_present?(collected)
  return unless payload["should_create_lead"] || explicit_closing_signal?

  Lead.create!(name: collected["name"], email: collected["email"], ...)
end

The model's judgment is an input to a decision, not the decision itself. That's the line I try to hold with any AI integration: the model can suggest, summarize, and extract — but a validated, application-level rule decides what gets persisted.

05Distributing leads with a transparent scoring algorithm

Once a lead exists, an operator can trigger automatic matching. The LeadDistributionServicescores every candidate listing against the lead's stated preferences — brand, model, price, location — and either auto-routes the lead (above a confidence threshold) or surfaces a suggestion for manual review, along with the specific trade-offs that made it a fuzzy match rather than a perfect one:

Iron Cars Shop admin leads dashboard with a Kanban pipeline: New, Contacted, Negotiation, Won
The admin lead pipeline — every lead the distribution algorithm scores lands here.

What I like about this design is that it never fails silently. Every suggestion comes with a matched_preferences and tradeoffs breakdown, so a human reviewer can see exactly why the algorithm proposed a given match before confirming it.

06Webhooks you can actually trust

The subscription billing runs on Stripe, and the webhook endpoint verifies every incoming event manually against Stripe's HMAC-SHA256 signature scheme — using a timing-safe comparison so the verification itself can't leak information through response-time differences:

app/services/billing/stripe_client.rbdef construct_webhook_event(payload:, signature:, webhook_secret:)
  timestamp, signatures = parse_signature_header(signature)
  expected = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, "#{timestamp}.#{payload}")

  raise StripeError, "Invalid webhook signature" unless
    signatures.any? { |s| ActiveSupport::SecurityUtils.secure_compare(s, expected) }

  JSON.parse(payload)
end

This is the kind of code that never shows up in a demo, but it's the difference between a payments integration you can put in front of real users and one you can't.


07What this project actually demonstrates

None of these decisions are exotic. Eager loading, keyset-style pagination, fingerprinted caching, schema-constrained AI calls, and signed webhooks are all well-documented patterns. What I wanted to show with Iron Cars Shop is what it looks like to apply them together, consistently, and with the trade-offs made explicit — in a project built solo, end to end, the same way I approach production systems at work.

The full source is public. If you want to see how any of this holds together beyond what fits in a blog post, the repository is the real documentation.

Read the code yourself

The full Iron Cars Shop repository is open on GitHub — architecture, tests, and all.

View on GitHubGet in touch