DEV Community

Ale Guro
Ale Guro

Posted on AI-assisted

ODSL: A Ruby DSL for Service Objects That LLMs Can Actually Understand

There's a pattern I've written — and seen written — hundreds of times in Ruby codebases:

class OrderService
  def initialize(user:, items:)
    @user  = user
    @items = items
  end

  def call
    catalog = Inventory.build(Pricing)
    total   = calculate_total(catalog, @items, @user)
    order   = Order.new(user: @user, total: total)
    payment = charge_customer(order)
    [order, payment]
  end

  private

  def calculate_total(catalog, items, user); ... end
  def charge_customer(order); ... end
end
Enter fullscreen mode Exit fullscreen mode

It works. Until it doesn't. Until call is 150 lines long, private helpers have side effects that depend on implicit ordering, and when a payment processor times out on step 4 of 7, you have no idea which steps completed.

I wrote ODSL to fix this — and what I didn't anticipate was how naturally it aligns with the way LLMs generate code.


The Problem with def call

Service objects are supposed to bring clarity. Instead they often just relocate complexity.

The real issues:

  1. Unnamed intermediate results — total, order, and payment are local variables. No audit trail, no checkpoints.
  2. No resumability — if step 4 fails, you start over from step 1.
  3. Hidden coupling — private methods call other private methods in a fragile, implicit order.
  4. Opaque in stack traces — when something breaks, the trace says call, not "it failed at the payment step."

What ODSL Does Differently

ODSL makes each step named, recorded, and resumable:

def create_order
  build(Inventory, Pricing)
    .get(:catalog)

  with(:catalog, :items, :user)
    .make(:calculate_total)
    .get(:order_total)

  with(:user, :order_total)
    .instantiate(Order)
    .get(:order)

  with(:order)
    .make(:charge_customer)
    .get(:payment)

  finish_with(:order, :payment)
end
Enter fullscreen mode Exit fullscreen mode

Read that out loud. It sounds like how you'd describe the workflow to a colleague:

"Build an inventory object with Pricing. Store it as catalog. With the catalog, items, and user, calculate the total. Store that as order_total. With the user and order total, create an Order. Charge the customer. Return the order and payment."

That's not an accident.


The LLM Angle: Why This Matters Now

When you ask an LLM to generate a service object, it will describe the business logic in natural language first — then translate it to code. The translation is where context is lost.

With a traditional service object, the LLM has to:

  • Infer which local variables flow into which private methods
  • Guess the implicit ordering of side effects
  • Fill in missing context about what each step produces

With ODSL, the code is already written the way an LLM thinks about it. Each line is a declarative sentence. Each .get(:var) names what was produced. Each with(:a, :b).make(:do_thing) names exactly what flows in and what happens next.

When I started using AI assistants to write ODSL services, I noticed something: the LLM barely needed prompting. I'd describe the workflow in plain English:

"Build a game object from Game and Player modules. Get it as game_object. With the player, game, and game_object, call new_game. Store it as game_instance. Instantiate a ScoreBoard with it."

And the generated code was nearly exactly right on the first try — because the DSL mirrors the description.

Contrast that with generating a traditional service: you have to prompt carefully about which variables go where, remind the model not to introduce hidden state, and then review for implicit ordering bugs. The abstraction gap between natural language and Ruby code is much wider.


Production Features

ODSL isn't just syntactic sugar. v0.3.0 added infrastructure for real production use:

Idempotency + step-level audit trail:

service.run(:process_order, key: "order-#{order.id}")
# Safe to call twice — no-op if already completed
Enter fullscreen mode Exit fullscreen mode
ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: "order-42")
ctx.status        # => "completed"
ctx.step_records  # => per-step audit trail
Enter fullscreen mode Exit fullscreen mode

Resume failed runs from the first incomplete step:

OrderService.resume(key: "order-42")
# Skips completed steps, re-runs from where it died
Enter fullscreen mode Exit fullscreen mode

Distributed locking (fail-fast, no waiting):

def process_order
  lock(:order_id)  # raises LockError immediately if already held
  # ...
end
Enter fullscreen mode Exit fullscreen mode

lock is a no-op when Redis isn't configured, so the same code works in development without any infrastructure.

Background execution:

service.run_in_background(:generate_report)
service.run_in_parallel(:invoice, :notify)
Enter fullscreen mode Exit fullscreen mode

All opt-in. No breaking changes.


The Design Principle

The core insight behind ODSL: code that reads like natural language is easier for both humans and machines to reason about.

Named steps create shared vocabulary between you, your team, and your AI assistant. When you .get(:order_total), that name appears in audit logs, stack traces, and error messages. Everyone — human or model — is speaking the same language.

The .get(:var) pattern in particular is what makes LLM-assisted development click. When you prompt an LLM with an ODSL service, you can point to each named result by name, ask it to extend a specific step, or debug a specific checkpoint. There's no ambiguity about what order_total is or where it came from.


Try It

gem install odsl
Enter fullscreen mode Exit fullscreen mode

Or in your Gemfile:

gem "odsl"
Enter fullscreen mode Exit fullscreen mode

If you're building Ruby service objects and experimenting with AI-assisted development, I'd love to hear how ODSL fits into that workflow.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

Named steps make recovery easier to reason about, but a payment timeout still needs an idempotency key or a status check before replay. How does the workflow distinguish a failed charge from a charge whose acknowledgement was lost?