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
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)