-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add preliminary guide drafts #1
base: main
Are you sure you want to change the base?
Changes from 7 commits
794cbab
a8e0daf
5e4cc74
ca81b20
1ca1394
f70a909
746a8c6
2e7e75b
c7e07e5
c3cc1e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"label": "Application architecture", | ||
"position": 2, | ||
"link": { | ||
"type": "generated-index", | ||
"description": "Organising a Hanami application: containers and dependency injection, providers, and slices." | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,354 @@ | ||||||
--- | ||||||
sidebar_position: 1 | ||||||
--- | ||||||
|
||||||
# Containers and dependencies | ||||||
|
||||||
In Hanami, the application code you add to your `/app` directory is automatically organised into a container. This container forms the basis of a depenency injection system, in which the dependencies of the components you create are provided to them automatically. | ||||||
|
||||||
Let's take a look at how this works in practice. | ||||||
|
||||||
Imagine we're building a Bookshelf notifications service for sending notifications to users of the Bookshelf platform. After running `hanami new notifications_service`, our first task is to send welcome emails. To achieve this, we want to provide a `POST /welcome-emails` action that will send a welcome email, probably via a send welcome operation, which in turn will want to render our email in both html and plain text. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
"Notifications" can mean so many things these days. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Second thought here: we've gone from creating a single "bookshelf" app in the first page of the guide to creating another "notifications_service" app in the second page of the guide. This feels like an undesirable amount of whiplash, and I don't think we want to go "all in on microservices" to our users right off the bat (or ever). It would be good if our guide could focus on building up a single app only, especially since Hanami also provides the tools to make that app more modular over time. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, excellent point, I've adjusted so there's just a Bookshelf app. |
||||||
|
||||||
As a first pass, we might add four Ruby classes to our `app` folder - our action, operation, and two renderers. | ||||||
|
||||||
On the file system, this might look like: | ||||||
|
||||||
```shell | ||||||
app | ||||||
├── actions | ||||||
│ └── welcome_emails | ||||||
│ └── create.rb | ||||||
└── emails | ||||||
└── welcome | ||||||
├── operations | ||||||
│ └── send.rb | ||||||
└── renderers | ||||||
├── html.rb | ||||||
└── text.rb | ||||||
``` | ||||||
|
||||||
When our application boots, Hanami will automatically create __instances__ of these components and register them in its __app container__, under a key based on their Ruby namespace. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Ruby namespace" -> "class name" ? |
||||||
|
||||||
For example, an instance of our `NotificationsService::Emails::Welcome::Operations::Send` class will be registered under the key `"emails.welcome.operations.send"`. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be good to use examples with shorter names and flatter structure. Could we rename this example app to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, good call @solnic. I'll adjust. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think @solnic mentioned this somewhere? But it'd be good if we could use slightly shallower names for our guide - this quite deep name spacing could be a little confronting (even if it is how things tend to play out in practice). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've gone for @solnic's |
||||||
|
||||||
```ruby title="app/emails/welcome/operations/send.rb" | ||||||
# frozen_string_literal: true | ||||||
|
||||||
module NotificationService | ||||||
module Emails | ||||||
module Welcome | ||||||
module Operations | ||||||
class Send | ||||||
def call(name:, email_address:) | ||||||
puts "Sending greetings to #{name} via #{email_address}!" | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
We can see this in the Hanami console if we boot our application and ask what keys are registered with the app container: | ||||||
|
||||||
```ruby | ||||||
bundle exec hanami console | ||||||
|
||||||
notifications_service[development]> Hanami.app.boot | ||||||
=> NotificationsService::App | ||||||
|
||||||
notifications_service[development]> Hanami.app.keys | ||||||
=> ["notifications", | ||||||
"settings", | ||||||
"routes", | ||||||
"inflector", | ||||||
"logger", | ||||||
"rack.monitor", | ||||||
"actions.welcome_emails.create", | ||||||
"emails.welcome.operations.send", | ||||||
"emails.welcome.renderers.html", | ||||||
"emails.welcome.renderers.text"] | ||||||
``` | ||||||
|
||||||
To fetch our welcome email send operation from the container, we ask for it by its `"emails.welcome.operations.send"` key: | ||||||
|
||||||
```ruby | ||||||
notifications_service[development]> Hanami.app["emails.welcome.operations.send"] | ||||||
=> #<NotificationsService::Emails::Welcome::Operations::Send:0x000000010df0a1a0> | ||||||
|
||||||
notifications_service[development]> Hanami.app["emails.welcome.operations.send"].call(name: "New user", email_address: "[email protected]") | ||||||
Sending greetings to New user [email protected]! | ||||||
``` | ||||||
|
||||||
Most of the time however, you won't use the container directly via `Hanami.app`, but will instead make use of the container through the dependency injection system it supports. Let's see how that works! | ||||||
|
||||||
## Dependency injection | ||||||
|
||||||
Dependency injection is a software pattern where, rather than a component knowing how to instantiate its dependencies, the dependencies are instead provided to it. This means its dependencies can be abstract rather than hard coded, making the component more flexible, reusable and easier to test. | ||||||
|
||||||
To illustrate, here's an example of a welcome email send operation which _doesn't_ use dependency injection: | ||||||
|
||||||
```ruby title="app/emails/welcome/operations/send.rb" | ||||||
# frozen_string_literal: true | ||||||
|
||||||
require "acme_email/client" | ||||||
|
||||||
module NotificationsService | ||||||
module Emails | ||||||
module Welcome | ||||||
module Operations | ||||||
class Send | ||||||
def call(name:, email_address:) | ||||||
return unless Hanami::Settings.new.email_sending_enabled | ||||||
|
||||||
AcmeEmail::Client.new.deliver( | ||||||
to: email_address, | ||||||
subject: "Welcome!", | ||||||
text_body: Renderers::Text.new.call(name: name), | ||||||
html_body: Renderers::Html.new.call(name: name) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder whether we could actually give these methods besides call, just to underscore that DI in Hanami does not mean "design every class to have a single call method". In fact, I wonder if we could change the dependency to be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've gone with one renderer that has render_text and render_html methods and no |
||||||
) | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
This component has four dependencies, each of which is a "hard coded" reference to a concrete Ruby class: | ||||||
|
||||||
- `Hanami::Settings`, used to check whether email sending is enabled in the current environment. | ||||||
- `AcmeEmail::Client`, used to queue the email for delivery via the third party Acme Email service. | ||||||
- `Renderers::Text`, used to render the text version of the welcome email. | ||||||
- `Renderers::Html`, used to render the html version of the welcome email. | ||||||
|
||||||
To make our send welcome email operation more resuable and easier to test, we could instead _inject_ its dependencies when we initialize it: | ||||||
|
||||||
```ruby title="app/emails/welcome/operations/send.rb" | ||||||
# frozen_string_literal: true | ||||||
|
||||||
module NotificationsService | ||||||
module Emails | ||||||
module Welcome | ||||||
module Operations | ||||||
class Send | ||||||
attr_reader :email_client | ||||||
attr_reader :settings | ||||||
attr_reader :text | ||||||
attr_reader :html | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO these are unnatural names for these dependencies. Could we make them (Depending on how things feel, we could also use this as a way to subtly introduce the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (I've another thought here, in that the text and HTML renderers might actually be reasonable to keep as concrete references to classes, but that would in part be contingent on how they're designed as libraries, and whether the renderers themselves need to access any other facets of the app. Given how this is a design decision that could go either way, I think it makes sense for the simplicity of this example to presume that all of these could be injected reps) |
||||||
|
||||||
def initialize(email_client:, settings:, text:, html:) | ||||||
@email_client = email_client | ||||||
@settings = settings | ||||||
@text = text | ||||||
@html = html | ||||||
end | ||||||
|
||||||
def call(name:, email_address:) | ||||||
return unless settings.email_sending_enabled | ||||||
|
||||||
email_client.deliver( | ||||||
to: email_address, | ||||||
subject: "Welcome!", | ||||||
text_body: text.call(name: name) | ||||||
html_body: html.call(name: name) | ||||||
) | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
As a result of injection, our component no longer has rigid dependencies - it's able to use any email client, settings object or renderers we provide. | ||||||
|
||||||
Hanami makes this style of dependency injection simple through an `include Deps[]` mechanism. Built into the app container (and all slice containers), `include Deps[]` allows a component to use any other component in its container as a dependency, while removing the need for any attr_reader or initializer boilerplate: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of saying "through an My vote would be for "through its There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done! |
||||||
|
||||||
```ruby title="app/emails/welcome/operations/send.rb" | ||||||
# frozen_string_literal: true | ||||||
|
||||||
module NotificationsService | ||||||
module Emails | ||||||
module Welcome | ||||||
module Operations | ||||||
class Send | ||||||
include Deps[ | ||||||
"settings", | ||||||
"email_client", | ||||||
"emails.welcome.renderers.text", | ||||||
"emails.welcome.renderers.html" | ||||||
] | ||||||
|
||||||
def call(name:, email_address:) | ||||||
return unless settings.email_sending_enabled | ||||||
|
||||||
email_client.deliver( | ||||||
to: email_address, | ||||||
subject: "Welcome!", | ||||||
text_body: text.call(name: name), | ||||||
html_body: html.call(name: name) | ||||||
) | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
## Injecting dependencies via `include Deps[]` | ||||||
|
||||||
In the above example the `include Deps[]` mechanism takes each given key and makes the relevant component from the app container available via an instance method of the same name. i.e. `include Deps["settings"]` makes the `settings` registration from the app container available anywhere in the class via the `#settings` method. By default, dependencies are made available under a method named after the last segment of their key. So `include Deps["emails.welcome.renderers.html"]` makes the html renderer available via the method `#html`. | ||||||
|
||||||
We can see `include Deps[]` in action in the console if we instantiate an instance of our send welcome email operation: | ||||||
|
||||||
```ruby | ||||||
notifications_service[development]> NotificationsService::Emails::Welcome::Operations::Send.new | ||||||
=> #<NotificationsService::Emails::Welcome::Operations::Send:0x000000010c843660 | ||||||
@email_client=#<AcmeEmail::Client:0x000000010c808858>, | ||||||
@html=#<NotificationsService::Emails::Welcome::Renderers::Html:0x000000010c843818>, | ||||||
@settings= | ||||||
#<NotificationsService::Settings:0x000000010c4bf340 | ||||||
@config=#<Dry::Configurable::Config values={:email_sending_enabled=>true}>>, | ||||||
@text=#<NotificationsService::Emails::Welcome::Renderers::Text:0x000000010c858628>> | ||||||
``` | ||||||
|
||||||
We can provide different dependencies during initialization: | ||||||
|
||||||
```ruby | ||||||
notifications_service[development]> NotificationsService::Emails::Welcome::Operations::Send.new(email_client: "another client") | ||||||
=> #<NotificationsService::Emails::Welcome::Operations::Send:0x000000010c1ded88 | ||||||
@email_client="another client", | ||||||
@html=#<NotificationsService::Emails::Welcome::Renderers::Html:0x000000010c1df1c0>, | ||||||
@settings= | ||||||
#<NotificationsService::Settings:0x000000010c4bf340 | ||||||
@config=#<Dry::Configurable::Config values={:email_sending_enabled=>true, :acme_api_key=>"sdf"}>>, | ||||||
@text=#<NotificationsService::Emails::Welcome::Renderers::Text:0x000000010c28ca50>> | ||||||
``` | ||||||
|
||||||
This behaviour is particularly useful when testing, as you can substitute one or more components to test behaviour. | ||||||
|
||||||
In this unit test, we substitute each of the operation's dependencies in order to unit test its behaviour: | ||||||
|
||||||
```ruby title="spec/unit/emails/welcome/operations/send_spec.rb" | ||||||
RSpec.describe NotificationsService::Emails::Welcome::Operations::Send, "#call" do | ||||||
subject(:send) { | ||||||
described_class.new( | ||||||
email_client: email_client, | ||||||
settings: settings, | ||||||
text: text, | ||||||
html: html | ||||||
) | ||||||
} | ||||||
|
||||||
let(:email_client) { double(:email_client) } | ||||||
let(:text) { double(:text, call: "Welcome to Bookshelf Bookshelf user") } | ||||||
let(:html) { double(:html, call: "<p>Welcome to Bookshelf Bookshelf user</p>") } | ||||||
|
||||||
context "when email sending is enabled" do | ||||||
let(:settings) { double(:settings, email_sending_enabled: true) } | ||||||
|
||||||
it "delivers an email using the email client" do | ||||||
expect(email_client).to receive(:deliver).with( | ||||||
to: "[email protected]", | ||||||
subject: "Welcome!", | ||||||
text_body: "Welcome to Bookshelf Bookshelf user", | ||||||
html_body: "<p>Welcome to Bookshelf Bookshelf user</p>" | ||||||
) | ||||||
|
||||||
send.call(name: "Bookshelf user", email_address: "[email protected]") | ||||||
end | ||||||
end | ||||||
|
||||||
context "when email sending is not enabled" do | ||||||
let(:settings) { double(:settings, email_sending_enabled: false) } | ||||||
|
||||||
it "does not deliver an email" do | ||||||
expect(email_client).not_to receive(:deliver) | ||||||
|
||||||
send.call(name: "Bookshelf user", email_address: "[email protected]") | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
Exactly which dependency to stub using RSpec mocks is up to you - if a depenency is left out of the constructor within the spec, then the real dependency is resolved from the container. Every test can decide exactly which dependencies to replace. | ||||||
|
||||||
## Renaming dependencies | ||||||
|
||||||
Sometimes you want to use a dependency under another name (either because two dependencies end with the same suffix, or just because it makes things clearer in a different context). | ||||||
|
||||||
This can be done with `include Deps[]` like so: | ||||||
|
||||||
```ruby | ||||||
module NotificationsService | ||||||
class NewBookNotification | ||||||
include Deps[ | ||||||
"settings", | ||||||
send_email_notification: "emails.book_added.operations.send", | ||||||
send_slack_notification: "slack_notifications.book_added.operations.send" | ||||||
] | ||||||
|
||||||
def call(...) | ||||||
send_email_notification.call(...) if settings.email_sending_enabled | ||||||
send_slack_notification.call(...) if settings.slack_sending_enabled | ||||||
end | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
## Opting out of the container | ||||||
|
||||||
Sometimes it doesn’t make sense for something to be put in the container. For example, Hanami provides a base action class at `app/action.rb` from which all actions inherit. This type of class will never be used as a dependency by anything, and so registering it in the container doesn’t make sense. | ||||||
|
||||||
For once-off exclusions like this Hanami supports a magic comment: `# auto_register: false` | ||||||
|
||||||
```ruby title="app/action.rb" | ||||||
# auto_register: false | ||||||
# frozen_string_literal: true | ||||||
|
||||||
require "hanami/action" | ||||||
|
||||||
module NotificationsService | ||||||
class Action < Hanami::Action | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
Another alternative for classes you do not want to be registered in your container is to place them in `/lib`. | ||||||
|
||||||
If you have a whole class of objects that shouldn't be placed in your container, you can configure your Hanami application (or slice) to exclude an entire directory from auto registration by adjusting its `no_auto_register_paths` configuration. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "whole class of objects" -> "whole category of objects" (we're already talking about ruby classes and files here, so probably helpful to avoid confusing the terms?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BTW, I actually thought this sentence was adding more detail to the one above about
Also, on the lib stuff, it'd probably be useful to explain that lib/[app_namespace]/ is still autoloaded? And that the rest of lib is still available to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, yep, thanks - changed that order and added a breakout box with that require and autoloading explanation. |
||||||
|
||||||
Here for example, the `app/structs` directory is excluded: | ||||||
|
||||||
```ruby title="config/app.rb" | ||||||
# frozen_string_literal: true | ||||||
|
||||||
require "hanami" | ||||||
|
||||||
module NotificationsService | ||||||
class App < Hanami::App | ||||||
config.no_auto_register_paths << "structs" | ||||||
end | ||||||
end | ||||||
``` | ||||||
|
||||||
## Container behaviour: prepare vs boot | ||||||
|
||||||
Hanami supports a **prepared** state and a **booted** state. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'd be helpful to make it clear that these are states on the Hanami app. |
||||||
|
||||||
### Hanami.prepare | ||||||
|
||||||
When you call `Hanami.prepare` (or use `require "hanami/prepare"`) Hanami will make its app and slices available, but components within containers will be **lazily loaded**. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning! Mention of "slices", but we haven't introduced that concept yet. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, good call, I've removed any reference to slices anywhere before we get to Slices in the guides (which at this point is only in the Slices doc). |
||||||
|
||||||
This is useful for minimizing load time. It's the default mode in the Hanami console and when running tests. | ||||||
|
||||||
It can also be very useful when running Hanami in serverless environments where boot time matters, such as on AWS Lambda, as Hanami will instantiate only the components needed to satisfy a particular web request or operation. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TBH I would leave this out for now. Right now we're not focused on the serverless use case. And when we get to that, I'd like to do a lot more than just say "prepare instead of boot the app". |
||||||
|
||||||
### Hanami.boot | ||||||
|
||||||
When you call `Hanami.boot` (or use `require "hanami/boot"`) Hanami will go one step further and **eagerly load** all components in all containers up front. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning! Mention of "all containers" even though we've only talked about one. Given we're at this early stage of the guide, can we hide away the notion of multiple slices/containers, saving those for when we get to that particular part of the guide? |
||||||
|
||||||
This is useful in contexts where you want to incur initialization costs up front, such as when preparing your application to serve web requests. It's the default when running via Hanami's puma setup (see `config.ru`). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would say that the container forms the basis of a "component management system", of which dependency injection is one part.
Defining what a "component" is here (as well as a "dependency") would be useful since we'll likely want to refer to "component" across the rest of the guides.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can also rely on the "Dependency injection" section below to introduce the idea of dependencies and DI as a concept.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great, thanks, yes. I've tweaked this intro to put that component management system first, and had a shot at explaining what a component is and being clearer with a definitely of dependency et al if you're able to take another read @timriley.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@timriley maybe let's wait until it's in Hugo for more feedback though.