Best Practices for data transforms

Dear Hanami,

This is a question about best practices for transforms.

TL;DR Where are we putting our transformation/normalization logic for create/update methods in our Repos?

I have a contract called NewApplicationContract with the optional field secret. However the actual schema for Applications requires a secret_digest. Ideally if no secret is provided we’ll generate one with SecureRandom.hex(32) but then we need to hash it with BCrypt before we store it in the database. The logic for doing so is pretty straight forward:

module IDP
  module Repos
    class ApplicationRepo < DB::Repo
      def create(validate: true, **attributes)
        if validate
          validator = Slice['contracts.new_application_contract'].call(application: attributes)
          return Failure(validator.errors) unless validator.success?
        end

        secret                     = attributes.delete(:secret) || SecureRandom.hex(32)
        attributes[:secret_digest] = BCrypt::Password.create(secret)

        application = applications.changeset(Changesets::Application::Create, attributes).commit
        Success([application, secret])
      rescue StandardError => e
        Failure(e)
      end
    end
  end
end

However while this particular create method is pretty straight forward, it’s not sustainable. What happens when I have a repo that needs 5 or 10 transformations done to the input? For example:

def create(validate: true, **attributes)
  if validate
    validator = Slice['contracts.new_application_contract'].call(application: attributes)
    return Failure(validator.errors) unless validator.success?
  end

  secret = attributes.delete(:secret) || SecureRandom.hex(32)
  attributes[:secret_digest] = BCrypt::Password.create(secret)

  attributes[:host] = attributes[:host].downcase.strip

  if attributes[:redirect_uris].is_a?(Array)
    attributes[:redirect_uris] = attributes[:redirect_uris].reject(&:empty?).map do |redirect_uri|
      redirect_uri.downcase.strip
    end
  end

  application = applications.changeset(Changesets::Application::Create, attributes).commit

  Success(application:, secret:)
rescue StandardError => e
  Failure(e)
end

Now the method has become way more complex and is arguably doing way too many things. Does this type of logic belong in a changeset? I tried introducing a “transformer” layer but it felt weird and I couldn’t figure out dry-transform well enough to pull it off (defining custom functions in dry-transform seems to be a whole ordeal). But defining this in a changeset seems awkward as well:

module IDP
  module Changesets
    module Application
      class Create < DB::Changeset::Create
        map do |data|
          secret = data.delete(:secret) || SecureRandom.hex(32)
          data[:secret_digest] = BCrypt::Password.create(secret)
        end
      end
    end
  end
end

In this scenario we have a few issues

  1. I can’t use this for different input scenarios
  2. I can’t expose the secret in the result of the create method anymore
  3. I have to create a Create, Update, and Delete changeset for every relation

Which brings me to the actual questions at hand: Where are we meant to put our normalization and transformation logic for create/update methods from our Repos? I suppose I could throw this in an operation but even there I have questions on best practices for organization. Does anyone have more verbose documentation on how to use dry-transform specifically on how to define custom functions? Would a transformers layer make sense in a slice? What patterns are you implementing to deal with this type of work?