Skip to content
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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
8 changes: 8 additions & 0 deletions docs/application-architecture/_category_.json
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."
}
}
354 changes: 354 additions & 0 deletions docs/application-architecture/containers.md
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.
Copy link
Member

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.

Copy link
Member

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.

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

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.


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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
Imagine we're building a Bookshelf notifications service for sending email 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.

"Notifications" can mean so many things these days.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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"`.
Copy link
Member

Choose a reason for hiding this comment

The 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 Bookshelf (especially that this is what we already use in the Getting Started) and use flatter namespace like Emails::Welcome::Send? or even Operations::SendWelcomeEmail?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, good call @solnic. I'll adjust.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone for @solnic's Operations::SendWelcomeEmail suggestion and simplified the examples by removing settings too. Happy to adjust further if needed.


```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)
Copy link
Member

Choose a reason for hiding this comment

The 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 email_renderer and give it render_html and render_text methods? This would have the added benefit of reducing the number of things we need to talk about here from 4 to 3.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 #call :)

)
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO these are unnatural names for these dependencies. Could we make them render_text or text_renderer?

(Depending on how things feel, we could also use this as a way to subtly introduce the local_name: "name.of.dep" syntax for Deps in the following section)

Copy link
Member

Choose a reason for hiding this comment

The 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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of saying "through an include Deps[] mechanism", could we give this a friendlier name that we can then use without such formalism in subsequent mentions?

My vote would be for "through its Deps mixin". We're going to be talking about this a lot, so we want to make sure that we can make it easy for ourselves (and others) to do so naturally in all kinds of contexts.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

The 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 /lib. Perhaps it would be better to reverse the order:

  • magic comment
  • no_auto_register_paths
  • lib

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 requiere explicitly if needed. (Maybe that's too much detail here; you let me know what you think).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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**.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning! Mention of "slices", but we haven't introduced that concept yet.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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`).
Loading