Dry::Container - Thoughts on multiple containers per module

Hi folx!, I’ve been thinking about leveraging the resolution of dependencies from the Class level as a way to create a registry of schemas/contracts that allow me to help external callers of my module to resolve and utilize the schemas and get better information on what data is required to perform actions in my service. I can do this on the Container I am currently using for IoC but it seems like it might be hard for others to know what dependencies are intended for external use and which ones are intended to support internal use cases. The notion of a Registry creates some semantics and boundaries around the internal, vs external, resolvable dependencies. So I’ve been thinking of something like the following illustrates the high points of my idea:

# pull the contract and use to validate the payload matches the internal needs
class SomeplaceInRailsLand
  def call(data)
    # illustrating external resolution
    contract = Documents::Registry.resolve("contracts.special_document_contract")
    contract
      .call(data)
      .bind { |payload| Documents::SpecialDocument.archive(payload: payload) }
      .or { |err| handle_error(err) }
  end
end

module Documents
  class Container
    extend Dry::Container::Mixin

    register "service" do
      DocService.new
    end
  end

  class Registry
    extend Dry::Container::Mixin

    register "contracts.special_document_contract" do
      DocumentContract.new
    end
  end

  Import = Dry::AutoInject(Container)

  class DocumentPayload
    extend Dry::Initializer
  end

  class DocumentContract < Dry::Validation::Contract
    include Dry::Monads[:result]
    def call(**args)
      res = super
      res.success? ? Success(DocumentPayload.new(res.to_h)) : Failure(res)
    end
  end

  class Archive
    # illustrating internal resolution
    include Import["service"]
    include Dry::Monads[:result]

    def call(payload)
      Success(service.perform)
    end
  end

  module SpecialDocument
    module_function

    # @param payload [DocumentPayload]
    def archive(payload:)
      # use the payload data to archive the document
      Archive.call(payload)
    end
  end
end

I think this might be nice as it decouples the contract used (I could even manage to register multiple versions or make forward-compatible adjustments to the schema without changing external callers and make type annotations (which remain internal to the service boundary).

I’m curious if 1) anyone else is using more than one container in this fashion and 2) for those with more experience, anything that stands out as an antipattern?

Thanks in advance!

Oh! That reminded me of something I did a couple years ago.

We have an event bus, and event handlers use a schema to validate that they should act on it. I needed to share certain event schemas across multiple handlers, so this is what I ended up with.

module Events
  extend Dry::Container::Mixin

  EventName = Types::String.constrained(format: /\A\w+(\.\w+){1,2}\z/)

  def self.contract(name, parent: Dry::Validation::Contract, &spec)
    klass = Class.new(parent, &spec)
    klass.define_singleton_method(:to_s) { "Events[#{name}]" }

    register EventName[name], klass.new, call: false
  end

  SchemaParents = Types::Coercible::Array.of(Types::String)

  def self.schema(name, **options, &spec)
    options[:parents] &&= SchemaParents.(options[:parents]).map { resolve(_1) }
    options[:processor_type] ||= Dry::Schema::JSON

    processor = Dry::Schema.define(**options, &spec)
    register EventName[name], processor, call: false
  end
end

This allowed us to write not only shared event schemas/contracts but also to build upon others in the same module.

module Events
  schema "abstract.subscription" do
    required(:product_urn).filter(format?: Formats::RFC8141).value(Types::URN)
    optional(:metadata).hash
  end

  contract "subscription.paid" do
    schema Events["abstract.subscription"] do
      required(:paid_through).filter(:array, size?: 2) do
        each Types::String.constrained(format: Formats::ISO8601_DATE) | Types::Nil
      end
    end

    rule :paid_through do
      if value.last.blank?
        key.failure("only the first paid_through date may be blank")
      end
    end
  end
end

I ended up with a DSL like this because we were already using the event name string to identify events, adding constant names to the mix felt unnecessary.

2 Likes

Crafty!