agentic examples

Ports and Adapters

Ports and Adapters: the domain is the part of your app that would survive a framework migration - IF you kept it clean. Here the use-case (quote a shipment) is pure Ruby speaking only to PORTS; the adapters live at the edge; and Agentic is the delivery mechanism, replaced in the second act by a bare call to prove the domain never knew it was there. The proof is mechanical: the domain's source is scanned for framework constants.

Patterns Round 11 Luca Guidi exit 0

source on github

bundle exec ruby examples/ports_and_adapters.rb

a real captured run

PORTS AND ADAPTERS (the domain would survive the migration)

  act one - delivered by Agentic:
    plan ran the use-case: {:price_cents=>1080, :carrier=>"premium"}

  act two - delivered by a bare method call:
    same use-case, no orchestrator: {:price_cents=>600, :carrier=>"standard"}
    repository holds 2 quotes; the domain never knew who called.

  the purity scan: grep the domain's source for framework constants
    0 mentions of the framework in the domain. the dependency
    arrow points ONE way: the edge knows the center; the center
    has never heard of the edge.

  what Agentic added in act one wasn't the business logic - it was
  everything AROUND it: retry policy, lifecycle hooks, journaling,
  concurrency, the graph. that's the correct division of labor:
  frameworks orchestrate; domains decide. the ports (#rate_for,
  #save) are the entire vocabulary the domain needs from the
  world, and both adapters fit in six lines because the ports
  asked for so little. clean architecture isn't ceremony - it's
  the freedom to change your mind about everything but the truth.

source

# frozen_string_literal: true

# Ports and Adapters: the domain is the part of your app that would
# survive a framework migration - IF you kept it clean. Here the
# use-case (quote a shipment) is pure Ruby speaking only to PORTS;
# the adapters live at the edge; and Agentic is the delivery
# mechanism, replaced in the second act by a bare call to prove the
# domain never knew it was there. The proof is mechanical: the
# domain's source is scanned for framework constants.
#
#   bundle exec ruby examples/ports_and_adapters.rb
#
# Runs offline; exits 1 if the domain mentions the framework.

require class="s">"bundler/setup"
require class="s">"agentic"

Agentic.logger.level = class="y">:fatal

# --- the domain (would survive the migration) -----------------------------------
DOMAIN_SOURCE = <<~RUBY
  class QuoteShipment
    Result = Struct.new(class="y">:price_cents, class="y">:carrier, keyword_init: true)

    def initialize(rate_source:, quote_repository:)
      @rate_source = rate_source        # port: #rate_for(mode)
      @quote_repository = quote_repository # port: #save(result)
    end

    def call(mode:, weight:)
      rate = @rate_source.rate_for(mode)
      result = Result.new(price_cents: (weight * rate).round, carrier: rate > 5 ? class="s">"premium" : class="s">"standard")
      @quote_repository.save(result)
      result
    end
  end
RUBY
eval(DOMAIN_SOURCE) # standard:disable Security/Eval -- the string exists so the purity scan below is honest

# --- the adapters (edge; disposable) --------------------------------------------
class StaticRates
  def rate_for(mode) = {class="s">"air" => 9, class="s">"sea" => 2}.fetch(mode)
end

class MemoryQuotes
  def all = @all ||= []

  def save(result) = all << result
end

# --- act one: Agentic as the delivery mechanism ---------------------------------
repo = MemoryQuotes.new
use_case = QuoteShipment.new(rate_source: StaticRates.new, quote_repository: repo)

orchestrator = Agentic:class="y">:PlanOrchestrator.new
quote_task = Agentic:class="y">:Task.new(
  description: class="s">"quote", agent_spec: {class="s">"name" => class="s">"quote", class="s">"instructions" => class="s">"quote"},
  payload: {mode: class="s">"air", weight: 120}
)
orchestrator.add_task(quote_task, agent: ->(t) { use_case.call(**t.payload) })
orchestrator.execute_plan

puts class="s">"PORTS AND ADAPTERS (the domain would survive the migration)"
puts
puts class="s">"  act one - delivered by Agentic:"
puts class="s">"    plan ran the use-case: #{repo.all.last.to_h}"
puts

# --- act two: the framework leaves; the domain doesn't notice -------------------
bare = use_case.call(mode: class="s">"sea", weight: 300)
puts class="s">"  act two - delivered by a bare method call:"
puts class="s">"    same use-case, no orchestrator: #{bare.to_h}"
puts class="s">"    repository holds #{repo.all.size} quotes; the domain never knew who called."
puts

# --- the proof: scan the domain for framework constants -------------------------
leaks = DOMAIN_SOURCE.scan(/\b(?class="y">:Agentic|PlanOrchestrator|Task|CapabilityS\w+)\b/).uniq - [class="s">"Task"]
leaks += DOMAIN_SOURCE.scan(/\bAgentic::\w+/)
puts class="s">"  the purity scan: grep the domain's source for framework constants"
if leaks.empty?
  puts class="s">"    0 mentions of the framework in the domain. the dependency"
  puts class="s">"    arrow points ONE way: the edge knows the center; the center"
  puts class="s">"    has never heard of the edge."
else
  puts class="s">"    LEAKED: #{leaks.join(", class="s">")} - the domain is coupled to its delivery."
end
puts
puts class="s">"  what Agentic added in act one wasn't the business logic - it was"
puts class="s">"  everything AROUND it: retry policy, lifecycle hooks, journaling,"
puts class="s">"  concurrency, the graph. that's the correct division of labor:"
puts class="s">"  frameworks orchestrate; domains decide. the ports (#rate_for,"
puts class="s">"  #save) are the entire vocabulary the domain needs from the"
puts class="s">"  world, and both adapters fit in six lines because the ports"
puts class="s">"  asked for so little. clean architecture isn't ceremony - it's"
puts class="s">"  the freedom to change your mind about everything but the truth."

exit(leaks.empty? ? 0 : 1)