It’s time to build Hanami 2.2!
The main focus for this version will be the integration of ROM as our DB layer.
In this post, I outline the user experience we’d like to build for this, along with notes on critical bits of implementation.
I also invite your feedback! If there are any aspects of this proposal that are not clear, or which you think could be changed for a better user experience, please let me know!
Principles
Before we go into details, let me outline some principles that underpin our goals here:
- It’s just ROM. We want to elevate ROM for Hanami users. Not to hide it away under another level of abstraction, but rather to provide the world’s best ROM experience as part of our full stack framework. All ROM features should be available to the user.
- Streamline the essentials, while establishing DB layer separation. Repos are your app’s front door to persistence, while relations are the home for your low-level query logic. Structs wrap the values you pass around your app. All are given special top-level folders by default (
repos/
,relations/
,structs/
), while thedb/
directory exists as your home for the rest of your persistence logic, which should be kept separate from the rest of your app. Along with this, provide a full-powered CLI experience to make it easy to drive all aspects of the DB layer. - Preserve slice independence. Slices can already work as wholly independent parts of a Hanami app. They can have their own routes, actions, views, assets, and business logic. We should make it possible to build an independent DB layer within each slice too, while still allowing whole-of-app conveniences and sharing where possible.
Proposal
tl;dr
config/
db/
migrate/*
structure.sql
app/
db/
relation.rb # TestApp::DB::Relation < Hanami::DB::Relation
repo.rb # TestApp::DB::Repo < Hanami::DB::Repo
struct.rb # TestApp::DB::Struct < Hanami::DB::Struct
relations/
cafes.rb # TestApp::Relations::Cafes < TestApp::DB::Relation
repos/
cafe_repo.rb # TestApp::Repos::CafeRepo < TestApp::Repo
structs/
cafe.rb # TestApp::Structs::Cafe < TestApp::DB::Struct
To go over the details, we’ll start from the bottom up.
A config/db/
directory holds migrations and DB structure dump
config/
db/
migrate/*
structure.sql
The contents of config/db/
directory will be familiar to Hanami (and Rails) users. The one adjustment here is that we place it under config/
rather than in the app root. This allows us to preserve this same structure within slices as well as the app (more on this further below).
Two directories hold ROM runtime components: app/relations/
and app/db/
app/
db/
relation.rb # TestApp::DB::Relation < Hanami::DB::Relation
relations/
cafes.rb # TestApp::Relations::Cafes < TestApp::DB::Relation
Relations are the primary way of interacting with database tables, and in many apps, these will be the only ROM runtime components users need, so we recognise their prominence by giving them a convenient top-level directory. We will still encourage repos as the primary DB interaction layer (more on these later), but relations are important in building any app’s DB layer, so we don’t want the user to dive too deeply to get at them.
The base relation class will be generated into an app/db/
directory, whose purposes is to keep all other ROM runtime components. The idea here is to provide some structural reinforcement directory that the persistence layer is separate from the domain layer, courtesy of this dedicated directory and matching namespace.
Although relations have their own top-level directory, the base class is in app/db/
to reinforce that relations still belong to this DB layer, despite their placement outside of it for convenicence.
We’ll use the base app relation class (TestApp::DB::Relation
) to avoid the awkward standard ROM subclassing syntax for relations (MyRelation < ROM::Relation[:sql]
). The bast relation class will ensure the appropriate adapter type for the relation is set based on the app’s ROM config. To start with, we will support SQL only. Users needing other adapters can still fall back to the existing ROM syntax.
Other ROM runtime components will go into this app/db/
directory. More on this below.
Other ROM runtime components live in subdirectories under app/db/
ROM provides a range of other components that can be used when building up a persistence layer: changesets, commands and mappers. We’ll support generating and (where required) loading these components from the following directories:
app/db/changesets/
app/db/commands/
app/db/mappers/
ROM will be configured explicitly by Hanami
Instead of relying on ROM’s built-in auto-registration feature, we’ll explicitly locate and register ROM’s runtime components (from app/relations/
and app/db/
) as part of ROM setup. This will ensure we can use the file structures noted above.
ROM will be configured by a built-in :db
provider
As part of its prepare
step, the app/slice will detect whether it contains a relations/
or db/
directory, and when it does, it will register a :db
provider that will configure ROM.
This will:
- Set ROM’s inflector to be the slice’s inflector
- Create an
:sql
ROM configuration - Provide a database URL by checking
settings.database_url
, and if that method does not exist, by falling back toENV["DATABASE_URL"]
- Explicitly load the ROM files (as noted above) rather than using
.auto_registration
- Register the ROM config object as
"db.config"
- Register the ROM container itself as
"db.rom"
ROM relations will be available directly in the Hanami app container
As part of with ROM setup above, all the relations in relations/
will be registered directly in the app/slice container. So e.g. app/relations/cafes.rb
will be available as app["relations.cafes"]
.
This is important to ensure users have a consistent experience when interacting with the Hanami containers. If we don’t do this, a large part of the persistence subsystem directory becomes a big special case that we then have to explain. In addition, we know from experience that working directly with relations can sometimes be useful, and being able to use all the standard Hanami facilities to do this (like the container and Deps
mixin) makes this much more intuitive than having to fetch them via e.g. app["db.rom"].relations[:cafes]
.
A change in dry-system will be needed to make it possible to resolve relations from the container. To resolve a relation, the :db
provider needs to have been started first, since that is what registers the relations in the first place. Currently, dry-system can implicitly start a provider at the time of lazy component resolution, but only if the provider shares the same name as the root key of the component. To address this, we’ll add provider aliases to dry-system. Thus, when Hanami’s :db
provider is registered along with an alias of :relations
, it will be started whenever a "relations.*"
component is lazily resolved for the first time.
Custom ROM changesets, commands and mappers will not be available directly via the Hanami app container. These are either registered by ROM against specific relations (commands and mappers), or expected to be accessed by class constants (changesets), so they do not make sense to access via the container.
The user may provide their own :db
provider to customize or replace the standard ROM configuration
The built-in :db
provider will use a provider source, which the user can also explicitly use to create their own :db
provider at config/providers/db.rb
and provide their own custom configuration. This gives the user a way to tailor the ROM setup while still allowing the Hanami built-in provider to do the standard work.
# Note `.configure_provider` instead of the standard `.register_provider`
Hanami.app.configure_provider :db do
configure do |config|
config.database_url = "sqlite::memory"
end
end
When a concrete config/providers/db.rb
file exists in the app, Hanami will use this provider instead of registering its own.
This also means that a user may choose to opt out entirely from using ROM by having a :db
provider that sets up something entirely different. For example:
# Note standard usage of register_provider, instead of configure_provider above
Hanami.app.register_provider :db do
# Wholly distinct provider logic goes here; will not use standard :db provider
end
Slices will have independent ROM setups by default, using a single shared database
There will be two possible arrangements for working with the database via slices:
- A fully independent ROM setup per slice
- Slices sharing the ROM setup from the app
Option (1) will be the default: full independence to each slice’s ROM setup.
In this arrangement, slices (just like the app) will have relations/
and db/
directories to contain ROM’s runtime components:
slices/[slice]/relations/
slices/[slice]/db/
When Hanami detects a slice with either of these directories, it will register a distinct :db
provider for that slice. It will work just like the app-level :db
provider, with a couple of differences:
- If an app-level
"db.config"
component is present, it will copy all the basic configuration (i.e. everything except the registered ROM runtime files: relations, etc.) from this object into a new config object for the slice’s own ROM setup. This will allow whole-app configurations to be made in one place (the app-level provider) and then shared across all the slices. - When registering ROM runtime components, it will only look inside the slice’s own
relations/
anddb/
directories.
In this arrangement, the user does need to do the extra work of defining relations for every table the slice needs to access. However, it means each slice can be made to know as little (or as much) about the database as the user decides, and it allows for slice-specific database logic to be isolated inside the slice, alongside all its other logic.
An important implementation concern for this: even with multiple per-slice ROM setups like this, if they’re all pointing to the same database, we’ll want these to share a single pool of database connections. This will require some special handling in our provider code and/or in the gem we introduce to hold our library-layer DB integration code.
A slice may use a distinct database for its independent ROM setup
A slice may not want to use the same database as the rest of the app. If the slice has its own distinct settings.database_url
, then that URL will be used instead of the app-level database URL. The ENV
will also be checked for a slice-specific ENV["SLICE_NAME__DATABASE_URL"]
(e.g. "BILLING__DATABASE_URL"
for a "billing"
slice).
In this arrangement, the slice must have its own config/db/
directory to contain that database’s migrations and structure dumps.
We will not generate config/db/
within slices by default, but we can raise a warning if a slice is configured with a distinct database but is missing this directory (and provide a generator command to generate this directly).
If there are multiple slices that share a database_url
that is different from the app-level database_url
, then only the slice containing a config/db/
directory will be used for the purpose of running migrations and dumping schema. These slices will also share a single pool of connections to that database, just like the approach for the app-level database mentioned above.
Slices may alternatively share the app-level ROM setup and relations, etc.
The second option for slices to work with the database is for slices to share the single ROM setup configured in the app.
In this arrangement, relations and other ROM components would be defined in app/relations/
and app/db/
only, and would need to cater to usage from all slices. Slices, then, would have only their own repo classes that would use these ROM components (more on this in the section below).
This will require the "db.rom"
component to be imported from the app into every slice. This can be partially achieved using existing Hanami features: by adding "db.rom"
to the app’s config.shared_app_component_keys
However, a more complete implementation of this arrangement will require all the ROM components registered in the app container (i.e. everything from app/relations/
) to be imported into each slice. To achieve this, we will introduce a single, more directed app-level setting. Something like:
module MyApp
class App < Hanami::App
config.db.import_from_parent = true
end
end
I’d like this setting to play two purposes: when configuring it the app (like above), it will be copied over into every slice, meaning we can enable the DB sharing for all slices with just a single line. But it will also allow specific individual slices to opt out of the arrangement, inside their own slice config:
module MySlice
class Slice < Hanami::Slice
config.db.import_from_parent = false
end
end
The exact name of this setting is still TBD. We want to find something that feels natural in both of the above contexts. Perhaps adding an alias like export_to_slices
will make it feel more natural when used on the app.
App and slices use repos
All of the ROM setup above is in aid of enabling repos for apps and slices.
These will be generated with a single base repo class per slice.
# app/db/repo.rb
module MyApp
module DB
class Repo < Hanami::DB::Repo
end
end
end
We will provide this Hanami::DB::Repo
parent class (which itself will inherit from ROM::Repository
) in order to provide slice-specific configuration of these repos. This will include:
- Injecting the slice’s
"db.rom"
component as a dependency; this is necessary for ROM repos to function in the first place. - Specifying a struct namespace for the slice’s repos (more on this in the section below)
A slice’s repo classes (subclasses of Repo
) are expected to live in a repos/
subdirectory, and be named using a singular noun followed by “repo”:
app/repo.rb
app/repos/cafe_repo.rb
app/repos/review_repo.rb
The trailing “repo” name makes it so these are more self-descriptive as components when injected as deps:
# Allows this dep to be referred to naturally as `cafe_repo`
# within the class
include Deps["repos.cafe_repo"]
Unlike the ROM components in relations/
and db/
, repo classes will not depend on living within this a structure. Repos are the live in the app layer, as the interface to the persistence layer (also why we will call them Hanami::Repo
as opposed to Hanami::DB::Repo
), so while we will provide repos/
as a conventional location, the user may choose to create repo classes wherever they like.
An empty repo class will look like:
module MyApp
module Repos
class CafeRepo < MyApp::DB::Repo
end
end
end
If the repo’s name matches a relation name, then we will automatically configure that relation as the repo’s root relation. If not, we will not configure a root for the repo.
App and slices provide structs
Along with repos, the app and slices will provide struct classes for encapsulating the values returned by their repos.
Their base repo class will automatically configure a struct_namespace
to match a Structs
module inside the app or slice, the equivalent of:
module MyApp
module DB
class Repo < Hanami::DB::Repo
struct_namespace MyApp::Structs
end
end
end
If this module does not yet exist, we will define it dynamically, so that repos work consistently in all contexts.
Structs will also have a base class:
# app/db/struct.rb
module MyApp
module DB
class Struct < Hanami::DB::Struct
end
end
end
This base struct class is in the DB
namespace for two reasons:
- To reinforce that these structs are dynamically defined based on the results of DB queries (it is a
ROM::Struct
in the end) - To leave the non-namespaced
MyApp::Struct
name available in case the app author wishes to use it for their own explicit, non-DB-defined struct classes
Each struct should be named after a singular noun and put in the structs/
subdirectory:
# app/structs/cafe.rb
module MyApp
module Structs
class Cafe < MyApp::DB::Struct
end
end
end
Standard generators will create db directories, repos, and structs
Generating a new Hanami app (as well as a new slice within an app) will generate the full complement of the files described above:
config/db/
config/db/migrate/
app/db/relation.rb
app/db/repo.rb
app/db/struct.rb
app/repos/
app/structs/
When generating a new app with hanami new
, these options will let you tailor the output:
--skip-db
to skip DB setup--database=sqlite
to use an sqlite database--database=postgres
to use a postgres database (the default)
When generating a slice with hanami generate slice
, these options will be available:
--skip-db
to skip DB setup--app-db
to share the app’s DB setup--slice-db
to use a distinct database setup, generatingconfig/db/
in the slice--database=sqlite
to use an sqlite database (relevant only for--slice-db
)--database=postgres
to use a postgres database (the default) (relevant only for--slice-db
)
Generators for individual components will also be available.
$ bundle exec hanami generate migration [name]
$ bundle exec hanami generate relation [name]
$ bundle exec hanami generate struct [name]
$ bundle exec hanami generate changeset [name]
$ bundle exec hanami generate command [name]
$ bundle exec hanami generate mapper [name]
$ bundle exec hanami generate repo [name]
Like all other generators, these will accept a --slice
argument to generate the component in the given slice.
hanami db
CLI commands will provide convenient access to database operations
The spread of hanami db
commands will be the same as Luca shared in his original proposal, with one adjustment to the arguments: where we previously had --database=app
to target the command to the database configured in the app, and e.g. --database=admin
to target the database configured in the admin slice, instead, we’ll have --app
and --slice=slice_name
to target the databases configured in the app and specified slice respectively. I want to reserve the --database
argument for the future, when we may support multiple databases within an app/slice.
Create:
$ bundle exec hanami db create # Creates all database
$ bundle exec hanami db create --app # Creates app database only
$ bundle exec hanami db create --slice=billing # Creates "billing" slice database
Drop:
$ bundle exec hanami db drop # Drops all databases
$ bundle exec hanami db drop --app # Drops app database
$ bundle exec hanami db drop --slice=billing # Drops "billing" slice database
Migrate:
$ bundle exec hanami db migrate # Migrates all databases
$ bundle exec hanami db migrate --app # Migrates app database
$ bundle exec hanami db migrate --slice=billing # Migrates "billing" slice database
Rollback:
$ bundle exec hanami db rollback # Rolls back app database
$ bundle exec hanami db rollback 3 # Rolls back app database
$ bundle exec hanami db rollback --app # Rolls back app database
$ bundle exec hanami db rollback 3 --app # Rolls back app database
$ bundle exec hanami db rollback --slice=billing # Rolls back "billing" slice database
$ bundle exec hanami db rollback 2 --slice=admin # Rolls back "billing" slice database
Prepare:
$ bundle exec hanami db prepare # Prepares all databases
$ bundle exec hanami db prepare --app # Prepares app database
$ bundle exec hanami db prepare --slice=billing # Prepares "billing" slice database
Structure dump:
$ bundle exec hanami db structure dump # Dumps the structure for all databases
$ bundle exec hanami db structure dump --app # Dumps the structure for the app database
$ bundle exec hanami db structure dump --slice=billing # Dumps the structure for the "billing" slice database
Structure load:
$ bundle exec hanami db structure load # Loads the structure for all databases
$ bundle exec hanami db structure load --app # Loads the structure for the app database
$ bundle exec hanami db structure load --slice=billing # Loads the structure for the "billing" slice database
Version:
$ bundle exec hanami db version # Prints all database versions
$ bundle exec hanami db version --app # Prints app database version
$ bundle exec hanami db version --slice=billing # Prints "billing" slice database version
Note: many of these CLI commands are already implemented (but not activated) in hanami-cli. They’ll need adjusting for the --app
/--slice
arguments and overall database setups noted above.
Rake task compatibility
For compatibility with Ruby hosting vendors targeting Rails, we’ll expose a db:migrate
Rake task (hidden from rake -T
output, if possible):
$ bundle exec rake db:migrate # Migrates all database
$ HANAMI_APP=true bundle exec rake db:migrate # Migrates the app database
$ HANAMI_SLICE=billing bundle exec rake db:migrate # Migrates the "billing" slice database
A new hanami-db gem will contain the custom code and rom dependency
We will create a new hanami-db
gem for any specific code needed to support the features above (aside from the code that needs to live in the hanami gem directly). This will also be the gem that holds the parent classes like Hanami::Repo
, Hanami::DB::Relation
, etc.
This gem would also manage the dependency on the rom
gems, to ensure compatibility with ROM changes over time.
Removing this gem from your app’s Gemfile
will also disable all of the DB integrations.
Not now: streamlined setup of multiple databases for a single ROM instance
ROM already supports connecting to multiple databases, but we won’t provide a streamlined way of configuring this for Hanami 2.2. This can be provided as a standalone enhancement in a future release.
Instead, we will look to provide simple “escape hatches” for the user who needs to use multiple databases like this.
For example, we can structure our :db
provider so that the user can add an after(:prepare)
hook where that can directly configure multiple gateways. We should at least have a test in our test suite to demonstrate that this works.
At minimum, the user will be able to provide their own independent :db
provider to configure ROM however they need it.
The CLI commands listed above won’t handle multiple databases by default, so any app requiring this will have to provide their own wrappers in the meantime.
Any incidental support for multiple databases will be considered “nice to haves” and will not be essential for the v2.2 release.
Not now: changes to ROM
All the arrangements above should be possible with the currently available 5.3.x versions of ROM.
My preference is to avoid ROM changes wherever possible, and instead focus exclusively on the Hanami integration.
Further reading
The proposal above is an evolution of this Luca’s original proposal for the Hanami DB layer: https://discourse.hanamirb.org/t/hanami-persistence-proposal-for-2-x/782**strong text**