As per my previous article, we've recently been working on moving towards splitting the rails monolith into engines with a focus on SOA. In order to progress this we needed to work out where the code needed to go. Broadly speaking, each engine would cater for a specific domain. Whilst these examples don't reflect the actual domains they help illustrate the point:
- Auth
- Crm
- Search
Once you have determined the top level domains, you can start categorising the models. Don't worry at this point if you have models with relationships to models in other domains, this is something that can be resolved later. Here are some hypothetical models which could be part of each of the aforementioned domains.
- Auth
- Account
- Organisation
- User
- Crm
- Lead
- Offer
- Opportunity
- Search
- Context
- Result
Now that you have categorised your models, you are ready to move everything. There are multiple steps, which don't necessarily need to be done in this order, but it's useful if you have to move a lot as it ensures consistency.
Remember the end goal here is to be able to interface with your models such as Auth::User.find(id: 1)
. I also
recommend that you regularly run your specs to give you an understanding of how things are progressing.
Disclaimer: there is probably a better way to do this, but I assure you there is a 'method to the madness'.
Move your models into the new folder
At the first hurdle you need to make a decision about whether to move the models directly into your engine or just a
folder in the main app models folder or not. To keep things simple, I will assume you will move them into a folder in
the main app such as app/models/auth
(for the first example).
You can move directly into an engine, but you might have other issues, especially if your models have other dependencies. Remember, once you have your models all namespaced up, you can always do another PR later (this is the approach I mostly took).
Once moved, don't forget to wrap with the namespace:
namespace Auth
class User < ApplicationRecord
# Do stuff here
end
end
Move any model specific specs
Now that you have moved your model, you will need to move the spec and also update the reference in the file. This will depend on your specific setup though.
RSpec.describe Auth::User do
# Do stuff here
end
Update your corresponding table name
There is a handy method for setting the table name in a given model. If the value is already set then you can probably leave it, but otherwise you will need to set it explicitly. If you are planning on changing your table name (something I don't recommend at this stage, then that would be out-of-scope for now). Here is an example, you can use a string or a symbol.
self.table_name = 'users'
# OR
self.table_name = :users
One thing I did quite a lot was to use the following, and works pretty consistently for those vanilla cases.
self.table_name = name.demodulize.tableize
Find-and-replace any references
The next step just involves using your find-and-replace functionality built into your IDE. Rather than just doing a blanket replace, I would go through each one at a time to ensure that it's a valid match. For example, we have a lot of services which are similarly named, so you need to ensure that they are not updated at this time.
Look for other model references
Depending on how your DB is structured you will also need to explicitly set the class_name
wherever you reference the
particular model. This will vary depending on your implementation, but here are some common examples.
belongs_to :user, class_name: 'Auth::User'
has_one :user, class_name: 'Auth::User'
has_many :users, class_name: 'Auth::User'
Update your factories
Depending on how you do your testing you may be using model factories. In the same way that we already updated the references, you will need to set the class value here. it's also good to move them into folders grouped by the domain, but this step is optional. I actually missed this originally so had to go through and do separate PR's after to move them.
FactoryBot.define do
factory :user, class: 'Auth::User' do
# Do stuff here
end
end
If you don't use factory bot you can skip this step.
Update any todos
We use rubocop and packwerk, so I needed to ensure that the file paths in the todo files reflect the new locations of the files. Again, this really does depend on wheteher you use any of these gems as part of your development workflow.
I found it easier to manually update the rubocop_todo.yml
files (the one for the main app and engines).
Single-table-inheritance (STI)
A powerful feature of rails applications is the ability to use a single database table to model multiple classes. If we
take our example from above User
, we may actually have multiple types of users such as InternalUser
, ExternalUser
or even ServiceUser
. Once setup, your rails application will store this class name in the DB directly in a type field.
In order to get around this we actually decided to update every reference in the DB to have the new type. For this, you
can create an active record migration and update each value when you deploy the code changes. It could look something
like the following (note the use of up
and down
methods in case you need to rollback).
class UpdateUserReferences < ActiveRecord::Migration[7.1]
def up
execute <<-SQL.squish
UPDATE some_table SET type = 'Auth::User' WHERE type = 'User'
SQL
end
def down
execute <<-SQL.squish
UPDATE some_table SET type = 'User' WHERE type = 'Auth::User'
SQL
end
end
The default field is type
, but again this may vary based on your implementation.
External dependencies
A word of warning at this point. If for some reason you expose your class names externally, potentially to power your front-end, then you will need to ensure that you are doing the necessary work to account for this. Even worse, if you expose these in an API that has external consumers you will need to 'break the contract', which can be problematic.
Final thoughts
Depending on your setup some of these steps may not be applicable to you, but here are some general things to think about as you complete this process. I've moved somewhere in the region of 150 models and have been burnt a number of times (mostly my own doing), so hopefully this will help.
Run your specs often
I can't stress this enough. If you have a good testing suite then make sure you use it. Due to the size of our suite it's not feasible to run everything locally and has to be run in parallel on the CI, but even running a few specs here and there can help weed out issues.
Test your work
Once you are done you need to be doing some sanity / smoke testing. I often found that just deploying the changes to staging and leaving them a few days would often mean issues came to light as people used that particular environment.
Don't forget any DB changes
Whether internal using STI (single-table-inheritance) or maybe some external reporting tooling, you need to ensure that you are making the necessary DB changes and getting them tested. I actually broke a reporting implementation when I forgot this step.
Move direct into the engine or not?
I'm on the fence about this one. Some of my colleagues had great success moving some models into the engine directly. I personally think that it's easier to limit the changes in a single PR and just do a separate one later once everything is in the correct namespace. You will probably find that you have issues with failing specs or the fact that your models are referencing and referenced by models from other domains, and you really need to unpick this first. Again this is out of scope for this process.
A PR per domain or not?
It can be useful to try and move a group of models at the same time. You can then hopefully limit your 'blast radius' in terms of testing, but equally for some of the more interconnected models it's often easier to do these in smaller PR's, perhaps with just a few related models or on their own. It really does depend, but I encourage you to do what seems sensible. I did at one stage end up doing a PR with around 700+ file changes, which is very silly I know, but it felt like a pragmatic approach to move these models together. As always with software engineering, the answer is often 'it depends'.
Don't reference the main app from your engine
If you do move files into the engine you may have a situation where you need files that are in the main app or even another engine. In this case you should look to extract this functionality into a shared gem first or refactor the code. Tools such as packwerk are useful because you can map your dependencies across files.
Useful links