Welcome back to my "Hanami, Why?" series. If you missed it, be sure to read Hanami, Why?: Introductions before you start this issue. It is my intent to keep these issues focused on singular subjects whenever possible. However, in today's issue we are going to cover several topics that are too small to warrant their own issue but still important to have for context. As I mentioned, Hanami has a lot of important abstractions, and today we are going to be covering what I consider "system level" abstractions. We have lots to cover, but this context will be important for a further understanding of how Hanami works.
Most of the time Ruby is pretty great at telling you what a particular file depends on via require. However, in frameworks like Rails everything is autoloaded for you, so there often are not any calls to require in the code base. Dependencies end up scattered through method bodies, and the only way to know what a class depends on is to read every line of it. Now, Hanami autoloads your code too, but it also gives you a way to declare your dependencies up front via the Deps module. Let us look at an example:
# app/actions/users/create.rb
include Deps["repos.user_repo"]
user = user_repo.create(request.params[:user])
response.redirect_to routes.path(:user, id: user.id)
end
end
end
end
end
Here the dependencies for our Users::Create action are clearly called out at the top of the file. Notice that including "repos.user_repo" gives us a method named user_repo. By default, the method name is the last segment of the key. If you do not like that name, you can alias it like so:
include Deps[users: "repos.user_repo"]
user = users.create(request.params[:user])
# ...
end
end
The important part to remember is that any class can have dependencies injected into it. It does not have to be an action, an operation, or a repo. Here is a plain old Ruby object with no superclass at all:
# app/slugify.rb
title.downcase.strip.gsub(/[^a-z0-9]+/, "-")
end
end
end
# app/post_publisher.rb
include Deps["repos.post_repo", "slugify"]
post_repo.create(**params, slug: slugify.call(params[:title]))
end
end
end
PostPublisher does not inherit from anything Hanami gives us. It just includes Deps and gets its dependencies like everything else.
The other thing I want to hit home is that the file is the key, and an instance is what gets injected. Every file in app/ is registered in your application's container under a key built from its path. app/slugify.rb becomes "slugify", and app/repos/post_repo.rb becomes "repos.post_repo". If you ever want to know where a dependency comes from, all you need to do is look at its key. You should also note that we never called Slugify.new.call. Hanami does not just load the code for you; it hands you a ready-to-use instance of that dependency.
Rough Edges
The first thing to be aware of is that Deps works by writing your class's initializer for you. If you need custom logic on initialization, you will have to work around the container to get it. You can still define your own initialize, but you must accept the injected dependencies and pass them along to super:
include Deps["repos.post_repo", "slugify"]
@reserved_slugs = Set.new(reserved_slugs).freeze
super(**deps)
end
end
end
Forget to call super and your dependencies will never be set. Notice also that reserved_slugs needs a default. The container builds your components by calling .new with no arguments, so anything a component needs has to come from a default or from Deps itself.
That leads to the second rough edge: Hanami registers every file in app/ as a component, whether you plan on injecting it or not. If you have a class that will never be injected, like a value object that takes arguments, you can tell Hanami to skip it with a magic comment at the top of the file:
# auto_register: false
Third, your keys are just strings. If you make a typo in include Deps["repos.post_rep"], Ruby will not catch it, and you will not find out until that code actually runs. Your editor's "go to definition" will not follow a string key either (although this is being worked on). The upside is that the key tells you exactly which file to open, so you are never more than one step away from the source.
Last, by default every component in your container is memoized. That means it is instantiated once, and that same instance is injected everywhere it is asked for. Your components should be stateless. Do not stash request data in instance variables and expect it to be gone by the next request. To make things trickier, components are not memoized in the test environment, so a bug like this can sail right through your test suite and only show up in production. If you truly need a fresh instance every time, you can opt a single component out with another magic comment:
# memoize: false
Have you ever opened the initializers directory in your Rails application and observed something like this?
initializers/
├── 001_high_priority.rb
├── 002_medium_priority.rb
└── 003_low_priority.rb
I know I have, and I am willing to wager you have seen something like this too. This is because initializers in Rails are loaded in alphabetical order. I have seen firsthand that failing to load dependencies in the initialization step in the correct order can cause hard-to-pinpoint failures throughout your application. Let us look at another example. Have you ever seen anything like this?
$redis = Redis.new(...)
Now, do not get me wrong, there are certainly better ways to go about this, but ultimately the problem being solved here is that Rails does not offer you a prescribed way of handling global components. This is where Hanami's providers come into play.
Providers are a way to register components with your containers. Providers have three stages in their life cycle: prepare, start and stop. All of the prepare blocks are loaded before any of the start blocks are, so this is where you want to do things like require dependencies. Providers are prepared and started automagically before your application boots, much like initializers. Providers are typically found in config/providers/ or slices/<slice>/config/providers. Let us look at an example of a provider and how you might use one.
Hanami.app.register_provider :redis, namespace: true do
prepare {
start do
register :client, Redis.new(url: slice["settings"].redis_url)
end
end
Now, there is actually quite a bit going on here, so let us break it down bit by bit.
- We are registering a namespaced provider named redis. Because it is namespaced, every component it registers lives under therediskey, so theclientcomponent we will get to in a moment can be called anywhere in your application viainclude Deps["redis.client"]. You could forgo the namespace altogether, in which case you would call the dependency viainclude Deps["client"]. If you go that route, I would definitely name itredis_clientinstead of justclient, but I hope you get the gist.
- We are requiring the redisgem in our prepare block. This ensures the library is ready to be used by the timestartis invoked. Remember, all of theprepareblocks are invoked before anystartblock is invoked. So, if you have several providers, each of them will be prepared before any of them are started.
- In our startblock, we are registering a component calledclientand assigning it an instance ofRedisas a value. You now have a global component that you can inject anywhere in your application.
- You can see we call something named sliceand pull settings off of it. In this example, thesliceis the Hanami application itself; however, if you were dealing with a slice's provider, theslicewould be that slice. We will probably cover slices in depth later in this series; for now, just know they are essentially a bounded context and are first-class citizens in a Hanami application.
Providers have all sorts of useful applications and are one of the many joys of working with a Hanami application. We should also take note that a provider can actually register multiple components:
Hanami.app.register_provider :argon, namespace: true do
prepare {
start do
register :hasher, Argon2::Password.method(:create)
register :verifier, Argon2::Password.new(...).method(:verify_password)
end
end
In this example you would inject these components via include Deps["argon.hasher", "argon.verifier"].
Rough Edges
The biggest rough edge here is that providers will behave differently in development and test than they do in production. In development and test, a provider's start cycle is only called when something asks for its component(s). This is to help ensure development and test environments stay fast, whereas Rails will load all of your initializers no matter what environment you are in. In production, Hanami.boot starts every provider upfront. So a provider with a bad setting, like a missing REDIS_URL, can sit quietly in development and then crash your app on boot in production.
Types and Validation
I know, I know: "ew, types". Types are actually a very useful abstraction and can be used to validate and normalize user inputs. Let us start off with an example:
= Dry::Types()
= Types::String.constrained(format: URI::MailTo::EMAIL_REGEXP, max_size: 255)
= EmailAddress.constructor { it&.downcase&.strip }
end
end
end
schema :users, infer: true do
attribute :email, Types::Normalized::EmailAddress
end
end
end
end
Alright, let us unpack this, shall we? Essentially, what we have done here is we have created Types::EmailAddress, which we can use to validate that any string is a valid email address. You can do that via Types::EmailAddress["[email protected]"]. If the email address is valid, it is returned as a string; if it is not, then an error is raised. You would want to use this in places like parameters to ensure the user is providing you valid values. Next, we have created Types::Normalized::EmailAddress, which carries all the same validation properties as Types::EmailAddress, but it will also ensure the email address is lowercase and devoid of whitespace. Last, we are telling the Users relation that the email field should be Types::Normalized::EmailAddress. This ensures all emails are normalized before they ever hit the database.
In Rails, normalize methods are typically tied to the model they do normalization in. This means anywhere you would need to normalize an email address, you are defining the same logic in multiple places. This is bad for several reasons, and you would likely want to abstract that out into something like a concern. Hanami gives you a framework that already expects this logic to be abstracted. Types are reusable and can be called throughout your Hanami application. Types can also be used to help you validate inputs as well.
Contracts
Hanami does not provide any generators for Contracts, and they are not considered first-class citizens by default in a new Hanami application. However, in my Hanami applications they are treated as such. Dry Validation is pulled in by Hanami as a dependency by default, so the pieces are already there for you. It is a simple matter of creating your base class and adding an app/contracts/ directory to your application. In a Rails application, validation is usually tied directly to individual models. Once again, this means if you have validation logic that is used in multiple places, it is either repeated in the individual models or abstracted out into a concern. Dry Validation gives us this abstraction for free.
params do
required(:user).filled(:hash) do
required(:email).filled(Types::EmailAddress)
required(:password).filled(:string, min_size?: 8)
end
end
end
end
end
I personally like to do my validation at the operation layer as this ensures params are validated any time a mutation occurs. That might look something like this:
include Deps["contracts.new_user_contract"]
validated = step validate_params(params)
# ...
end
private
result = new_user_contract.call(params)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
end
end
end
I can call this contract anywhere I would like throughout my application, meaning I can deploy my validation layer anywhere it is needed. Pro tip: the params method in a Hanami action can take a class as an argument.
params Contracts::NewUserContract
# ...
end
The last thing we need to cover about contracts is custom rules. The example above will ensure that an email is the right format, and that the password is at least eight characters long. What if I also want to validate that the email is unique? That is where rules come into play:
include Deps["repos.user_repo"]
params do
required(:user).filled(:hash) do
required(:email).filled(Types::EmailAddress)
required(:password).filled(:string, min_size?: 8)
end
end
rule user: :email do
next unless user_repo.email_exist?(value)
key.failure("email is already taken")
end
end
end
end
Now, personally, I do not allow my contracts to depend on repos. In my mind, the validation layer should be as light as possible, and injecting a database layer into the contract sort of violates that principle. I would typically handle this check in the operation or the repo layer. However, this is one of the beautiful things about Hanami: you get to decide how to structure your application.
Rough Edges
Configuration for contracts can be a bit confusing, and I often find myself playing with my internationalization files quite a bit trying to figure out the right keys to call when I want to customize an error message. However, I know this is on the Hanakai team's radar, and it is something we are looking to simplify after adding internationalization in Hanami 3. Additionally, picking the right kind of type can be confusing. Types::Integer will reject the value "42", but Types::Coercible::Integer will happily take it and return an integer to you. I do not view this as a rough edge as much as I view it as a bit of a learning curve. I can say I do not typically need to visit the dry types documentation anymore to figure out which type to use.
Alright, we have covered a substantial amount of ground today. I know we have not yet really dived into Hanami as an application framework, but I assure you that having this context will be very useful moving forward in this series. I do want to take a moment to thank you for the overwhelmingly positive reception to the introduction of this series. I do hope you found this issue helpful. I look forward to seeing you in the next issue, where we will be covering Actions, one of my favorite abstractions in Hanami.