Vanilla Rails is Plenty
A common critique of Rails is that it encourages a poor separation of concerns. That when things get serious, you need an alternative that brings the missing pieces. We disagree.
批评Rails的大多是指:Rails对前后端分离的支持比较差,随着系统的发展,需要新的方案来替代。这一点我不同意。
I have often heard this: vanilla Rails can only get you so far. At some point, apps become unmaintainable, and you need a different approach that brings the missing pieces, because Rails encourages a poor separation of concerns at the architectural level.
批评Rails的大多是指:Rails对前后端分离的支持比较差,随着系统的发展,需要新的方案来替代。这一点我不同意。
I have often heard this: vanilla Rails can only get you so far. At some point, apps become unmaintainable, and you need a different approach that brings the missing pieces, because Rails encourages a poor separation of concerns at the architectural level.
我经常听到:Vanilla Rails 纯Rails只能带你走这么远,也就是适合初创项目。应用程序变大之后难于维护,然后需要用别的办法弥补。因为Rails在架构级别对分离支持的不行。
The seminal [ˈsemɪnl] Domain Driven Design (DDD) book discusses these four conceptual layers: presentation, application, domain, and infrastructure. The application layer implements business tasks by coordinating work with the domain layer. But Rails only offers controllers and models: models include persistence via Active Record, and Rails encourages direct access to them from controllers. Critics argue that the application, domain, and infrastructure layers inevitably merge into a single mess of fat models. Indeed, alternatives always include additional buckets, such as services or use case interactors, at the application layer, or repositories, at the infrastructure layer.
I find this discussion fascinating because here at 37signals we are big fans of both vanilla Rails and Domain Driven Design. We don’t run into the alleged maintenance problems when evolving our apps, so here I would like to discuss how we organize our application code.
领域驱动设计(英语:Domain-driven design,缩写 DDD )是一种通过将实现连接到持续进化的模型 来满足复杂需求的软件开发方法。
表现层,应用层,domain和infrastructure
应用层实现业务
Rails仅仅提供控制器和models,直接通过控制器访问model
应用和domain、infrastructure合在一起组合成model
领域驱动设计(英语:Domain-driven design,缩写 DDD )是一种通过将实现连接到持续进化的模型 来满足复杂需求的软件开发方法。
表现层,应用层,domain和infrastructure
应用层实现业务
Rails仅仅提供控制器和models,直接通过控制器访问model
应用和domain、infrastructure合在一起组合成model
开创性的领域驱动设计(DDD)书讨论了这四个概念层:表示、应用程序、领域和基础设施。应用程序层通过与域层协调工作来实现业务任务。但Rails只提供控制器和模型:模型通过ActiveRecord包括持久性,Rails鼓励从控制器直接访问它们。批评者认为,应用程序、领域和基础设施层不可避免地合并成一堆庞大的模型。事实上,替代方案总是在应用层包括额外的桶,例如服务或用例交互器,或者在基础设施层包括存储库。
我觉得这场讨论很吸引人,因为在37signals,我们是vanilla Rails和领域驱动设计的忠实粉丝。我们在开发应用程序时不会遇到所谓的维护问题,所以我想在这里讨论一下我们如何组织应用程序代码。
我觉得这场讨论很吸引人,因为在37signals,我们是vanilla Rails和领域驱动设计的忠实粉丝。我们在开发应用程序时不会遇到所谓的维护问题,所以我想在这里讨论一下我们如何组织应用程序代码。
We don’t distinguish application and domain layers
We don’t separate application-level and domain-level artifacts. Instead, we have a set of domain models (both Active Records and POROs) exposing public interfaces to be invoked from the system boundaries, typically controllers or jobs. We don’t separate that API from the domain model, architecturally speaking.
We care a lot about how we design these models and the API they expose, we just find little value in an additional layer to orchestrate the access to them.
In other words, we don’t default to create services, actions, commands, or interactors to implement controller actions.
我们不区分应用程序层和域层
我们没有分离应用程序级和域级工件。相反,我们有一组域模型(Active Records和PORO),它们公开了要从系统边界调用的公共接口,通常是控制器或作业。从架构上讲,我们没有将该API与域模型分开。
我们非常关心我们如何设计这些模型以及它们所暴露的API,我们只是在一个额外的层中发现很少有价值来协调对它们的访问。
换句话说,我们不默认创建服务、动作、命令或交互程序来实现控制器动作。
Controllers access domain models directly
We are fine with plain CRUD accesses from controllers for simple scenarios. For example, this is how we create boosts for messages and comments in Basecamp:
class BoostsController < ApplicationController def create @boost = @boostable.boosts.create!(content: params[:boost][:content]) end
But more often, we perform these accesses through methods exposed by domain models.
For example, this is the controller to select the desired box for a given contact in HEY:
For example, this is the controller to select the desired box for a given contact in HEY:
class Boxes::DesignationsController < ApplicationController include BoxScoped before_action :set_contact, only: :create def create @contact.designate_to(@box) respond_to do |format| format.html { refresh_or_redirect_back_or_to @contact, notice: "Changes saved. This might take a few minutes to complete." } format.json { head :created } end end
Most of our controllers use this approach of accessing models directly: a model exposes a method, and the controller invokes it.
很多时候是controller调用的model暴露出来的方法。
Rich domain models
很多时候是controller调用的model暴露出来的方法。
Rich domain models
As opposed to anemic domain models, our approach encourages building rich domain models. We think of domain models as our application API and, as a guiding design principle, we want that one to feel as natural as possible.
Because we like to access business logic via domain models, some core domain entities end up offering much functionality. How do we avoid the issues related to the dreaded fat model problem? With two tactics:
- Using concerns to organize model’s code.
- Delegating functionality to additional systems of objects (AKA using plain object-oriented programming).
I’ll clarify with an example. A core domain entity in Basecamp is a Recording. Most elements a user manages in Basecamp are recordings — the original use case that motivated Rails’ delegated types.
You can do many things with recordings, including copying them to other places or incinerating them. Incineration is the term we use for “deleting data for good”. For the caller — e.g., controller or job — we want to offer a natural API:
与贫血的领域模型不同,我们的方法鼓励构建丰富的领域模型。我们认为领域模型是我们的应用程序API,作为一个指导性的设计原则,我们希望它尽可能自然。
因为我们喜欢通过域模型访问业务逻辑,所以一些核心域实体最终提供了很多功能。我们如何避免与可怕的Fat模型问题相关的问题?有两种策略:
与贫血的领域模型不同,我们的方法鼓励构建丰富的领域模型。我们认为领域模型是我们的应用程序API,作为一个指导性的设计原则,我们希望它尽可能自然。
因为我们喜欢通过域模型访问业务逻辑,所以一些核心域实体最终提供了很多功能。我们如何避免与可怕的Fat模型问题相关的问题?有两种策略:
- 使用关注点组织模型的代码。
- 将功能授权给其他对象系统(使用纯面向对象编程的AKA)。
我将举例说明。Basecamp中的核心域实体是录制。用户在Basecamp中管理的大多数元素都是记录——最初的用例激发了Rails的委托类型。
你可以用录音做很多事情,包括将它们复制到其他地方或将它们焚烧。焚烧是我们用来表示“永久删除数据”的术语。对于调用者(例如,控制器或作业),我们希望提供一个自然的API:
你可以用录音做很多事情,包括将它们复制到其他地方或将它们焚烧。焚烧是我们用来表示“永久删除数据”的术语。对于调用者(例如,控制器或作业),我们希望提供一个自然的API:
recording.incinerate recording.copy_to(destination_bucket)
But, on the inside, incinerating data and copying are pretty different responsibilities. So we use concerns to capture each:
class Recording < ApplicationRecord include Incineratable, Copyable end module Recording::Incineratable def incinerate # ... end end module Recording::Copyable extend ActiveSupport::Concern included do has_many :copies, foreign_key: :source_recording_id end def copy_to(bucket, parent: nil) # ... end end
I wrote an article about how we use concerns here, if you are interested.
Now, incineration and copying are involved operations. Recording wouldn’t be a good spot to implement those. Instead, it delegates the work itself to additional systems of objects.
For incinerating, Recording::Incineratable creates and executes a Recording::Incineration, which encapsulates the logic of incinerating a recording:
module Recording::Incineratable def incinerate Incineration.new(self).run end end
For copying, Recording::Copyable creates a new Copy record.
module Recording::Copyable extend ActiveSupport::Concern included do has_many :copies, foreign_key: :source_recording_id end def copy_to(bucket, parent: nil) copies.create! destination_bucket: bucket, destination_parent: parent end end
Here, things are more complex: Copy is a child of Filing. Filing is a common parent class for both the copy and move operations. When a filing is created, it enqueues a job that will eventually invoke its #process method. That method invokes file_recording, a template method to be implemented by child classes. When implementing that method, Copy creates a Recording::Copier instance to perform the copy.
在这里,事情更复杂:副本是存档的子项。归档是复制和移动操作的通用父类。当创建文件时,它会将最终调用其#process方法的作业排入队列。该方法调用file_recording,这是由子类实现的模板方法。在实现该方法时,Copy会创建一个Recording::Copier实例来执行复制。
在这里,事情更复杂:副本是存档的子项。归档是复制和移动操作的通用父类。当创建文件时,它会将最终调用其#process方法的作业排入队列。该方法调用file_recording,这是由子类实现的模板方法。在实现该方法时,Copy会创建一个Recording::Copier实例来执行复制。
module Recording::Copyable extend ActiveSupport::Concern included do has_many :copies, foreign_key: :source_recording_id end def copy_to(bucket, parent: nil) copies.create! destination_bucket: bucket, destination_parent: parent end end class Copy < Filing private def file_recording Current.set(person: creator) do Recording::Copier.new( source_recording: source_recording, destination_bucket: destination_bucket, destination_parent: destination_parent, filing: self ).copy end end end class Filing < ApplicationRecord after_create_commit :process_later, unless: :completed? def process_later FilingJob.perform_later self end def process # ... file_recording # ... end end
This example is not as simple as the incineration one, but the principle is the same: rich internal object models hidden behind high-level APIs on domain models. This doesn’t mean we always create additional classes to implement concerns, far from it, but we do when complexity justifies it.
Along with concerns, this makes the approach of classes with a large API surface work. If you are thinking about the Single Responsibility Principle (SRP), as Michael Feathers says in Working effectively with Legacy code you must differentiate between SRP violations at the interface level or the implementation level:
The SRP violation we care more about is violation at the implementation level. Plainly put, we care whether the class really does all of that stuff or whether it just delegates to a couple of other classes. If it delegates, we don’t have a large monolithic class; we just have a class that is a facade, a front end for a bunch of little classes and that can be easier to manage.
In our example, there are no fat models in charge of doing too many things. Recording::Incineration or Recording::Copier are cohesive classes that do one thing. Recording::Copyable adds a high-level #copy_to method to Recording’s public API and keeps the related code and data definitions separated from other Recording responsibilities. Also, notice how this is just good old object orientation with Ruby: inheritance, object composition, and a simple design pattern.
On a final note, one could argue that these three are equivalent:
最后一点,我们可以认为这三个是等价的:
最后一点,我们可以认为这三个是等价的:
recording.incinerate Recording::Incineration.new(recording).run Recording::IncinerationService.execute(recording)
We don’t believe they are: we strongly prefer the first form. On one side, it does a better job of hiding complexity, as it doesn’t shift the burden of composition to the caller of the code. On the other, it feels more natural, like plain English. It feels more Ruby.
我们不相信它们是:我们强烈喜欢第一种形式。一方面,它在隐藏复杂性方面做得更好,因为它不会将编写的负担转移到代码的调用方。另一方面,它感觉更自然,就像普通英语。感觉更像红宝石。
What about services?
我们不相信它们是:我们强烈喜欢第一种形式。一方面,它在隐藏复杂性方面做得更好,因为它不会将编写的负担转移到代码的调用方。另一方面,它感觉更自然,就像普通英语。感觉更像红宝石。
What about services?
One of the building blocks of DDD is services, which are meant to “capture important domain operations that can’t find a natural home in a domain entity or value object”.
We don’t use services as first-class architectural artifacts in the DDD sense (stateless, named after a verb), but we have many classes that exist to encapsulate operations. We don’t call those services and they don’t receive special treatment. We usually prefer to present them as domain models that expose the needed functionality instead of using a mere procedural syntax to invoke the operation.
For example, this is the code for signing up a new user in Basecamp via invitation tokens:
class Projects::InvitationTokens::SignupsController < Projects::InvitationTokens::BaseController def create @signup = Project::InvitationToken::Signup.new(signup_params) if @signup.valid? claim_invitation @signup.create_identity! else redirect_to invitation_token_join_url(@invitation_token), alert: @signup.errors.first.message end end end class Project::InvitationToken::Signup include ActiveModel::Model include ActiveModel::Validations::Callbacks attr_accessor :name, :email_address, :password, :time_zone_name, :account validate :validate_email_address, :validate_identity, :validate_account_within_user_limits def create_identity! # ... end end
So instead of having a SigningUpService in charge of the “signing up” domain operation, we have a Signup class that lets you validate and create an identity in the app. One can argue this is just a bit of syntax sugar away from being a service or even a form object. But, as I see it, it’s just plain object orientation with Ruby to give a domain concept a proper representation in code.
Also, we don’t make a big deal of distinguishing whether a domain model is persisted or not (Active record or PORO). From the business logic consumer’s point of view, that’s irrelevant, so we don’t capture the distinction between domain entities and value objects in code. They are both domain models to us. You can find many POROs in our app/models folders.
The dangers of isolating the application layer
My main problem with the idea of an isolated application layer is that people often take it way too far.
The original DDD book warns about the problem of abusing services:
Now, the more common mistake is to give up too easily on fitting the behavior into an appropriate object, gradually slipping towards procedural programming.
And you can find the same advice in Implementing Domain Driven Design:
Don’t lean too heavily toward modeling a domain concept as a Service. Do so only if the circumstances fit. If we aren’t careful, we might start to treat Services as our modeling “silver bullet.” Using Services overzealously will usually result in the negative consequences of creating an Anemic Domain Model, where all the domain logic resides in Services rather than mostly spread across Entities and Value Objects.
Both books discuss the challenges of isolating the application layer, starting with the nuances of differentiating domain and application services. Furthermore, they acknowledge that most layered DDD architectures are relaxed, with the presentation layer sometimes accessing the domain layer directly. The original DDD book states that what enables DDD is the crucial separation of the domain layer, noting that some projects don’t make a sharp distinction between the user interface and the application layers.
However, in the Rails world, you often see dogmatic takes that advocate against controllers directly talking to models with tremendous conviction. Instead, there should be an intermediary object to mediate between both — e.g. an application service from DDD or an interactor from Clean Architecture. I believe such nuance-free recommendations favor the appearance of either:
- Tons of boilerplate code because many of these application-level elements simply delegate the operation to some domain entity. Remember that the application layer should not contain business rules. It just coordinates and delegates work to domain objects in the layer below.
- An anemic domain model, where the application-level elements are the ones implementing the business rules, and domain models become empty shells carrying data around.
These approaches are often presented as a tradeoff-free answer to a very complex problem: how to design software properly. They often imply that good architecture happens as a consequence of using a discrete set of archetypes, which is not only very naive but incredibly misleading for the inexperienced audience. I hope that the alternative I presented here resonates with people looking for more pragmatic alternatives.
Conclusions
In our experience, this approach with vanilla Rails results in maintainable large Rails applications. As a recent example, we just launched Basecamp 4 built on top of Basecamp 3, the codebase of which is almost 9 years old, includes 400 controllers and 500 models, and serves millions of users every day. I don’t know if our approach would work at Shopify scale, but I am sure it would for most businesses using Rails out there.
Our approach reflects one of the Rails’ doctrine pillars: No one paradigm. I love architectural patterns, but a recurring problem in our industry is that people get very dogmatic when translating those into code. I think the reason is that simple strict recipes are very appealing when tackling a problem as complex as software development. 37signals’ code is the best I’ve seen in my career, and I say that as a spectator since I haven’t written most of it. In particular, it is the best incarnation of DDD principles I’ve seen, even if it doesn’t use most of its building blocks.
So if you abandoned the vanilla Rails path and now you are wondering if you really need those additional boilerplate classes whenever you need to handle some screen interaction, be sure that there is an alternative path that won’t compromise the maintainability of your application. It won’t prevent you from having to know how to write software — no alternative will — but it might bring you back to happy territory again.
Thanks to Jeffrey Hardy for his valuable feedback as I wrote this article. He’s one of the main contributors to this approach of architecting vanilla Rails applications I’ve come to learn and love.
Photo by Johannes Plenio on Unsplash
结论
根据我们的经验,这种使用普通Rails的方法会产生可维护的大型Rails应用程序。作为最近的一个例子,我们刚刚在Basecamp 3的基础上推出了Basecamp 4,它的代码库已有近9年的历史,包括400个控制器和500个型号,每天为数百万用户提供服务。我不知道我们的方法是否能在Shopify规模上发挥作用,但我确信,对于大多数使用Rails的企业来说,这是可行的。
我们的方法反映了Rails的原则支柱之一:没有一个范式。我喜欢架构模式,但我们行业中反复出现的一个问题是,人们在将这些模式转换为代码时会变得非常教条。我认为原因是,在解决像软件开发这样复杂的问题时,简单而严格的食谱非常有吸引力。37signals的代码是我职业生涯中见过的最好的代码,我作为一名观众这么说,因为我没有写过大部分代码。特别是,它是我见过的DDD原则的最佳体现,即使它没有使用其大部分构建块。
因此,如果您放弃了普通的Rails路径,现在您想知道,在需要处理一些屏幕交互时,是否真的需要这些额外的样板类,请确保有一个不会影响应用程序可维护性的替代路径。它不会阻止你知道如何编写软件——没有其他选择——但它可能会让你再次回到快乐的领域。
感谢Jeffrey Hardy在我写这篇文章时提供的宝贵反馈。他是我所学习和喜爱的这种构建普通Rails应用程序方法的主要贡献者之一。
照片由Johannes Plenio在Unsplash拍摄
阅读量: 681
发布于:
修改于:
发布于:
修改于: