Ruby on Rails - Bounded contexts via interface objects
ArticleIn this Article I’ll show you how to organize business classes in Ruby on Rails so your application can benefit from Bounded Contexts while still keep Rails conventions and best practices. Solution is also friendly for junior developers.
As this topic is quite extensive article is separated in following sections:
- What are Bounded Contexts
- Bounded Contexts via Interface Objects (theory around this pragmatic solution)
- Example Rails code
- Summary
- Comparison of other ways how to do Bounded Contexts in Rails
If something is too long to read please just skip to section you are interested in.
Article was drafted May 24, 2018, officially published May 30th 2019
What are Bounded Contexts
The point of Bounded Contexts is to organize the code inside business boundaries.
For example let say we are building an education application
in which you have teachers
, students
and their works
inside lessons
.
After lesson
is done (published) other students can comment
each other
works.
So two natural bounded contexts may be:
classroom
bounded context- creation of lesson and adding students to the lesson
- students receiving email notifications when they are invited to lesson
- students uploading their work files
- teachers receiving email notifications when they new work is uploaded to lesson
- publish the lesson (all the works)
public_board
bounded context- once the lesson is published students can comment on each other works
- students will be notified when new comments are added on their work
- mark lesson as favorite
As you can imagine both bounded contexts are interacting with same
models (Student
, Teacher
, Work
, Lesson
) just from different
business perspective.
That means you would place all related code & classes for classroom
to one folder
and all related code to public_board
to the other folder. As for the shared
models you would create own representation of those models in given
bounded context Classroom::Student
(ideally with own DB table)
and PublicBoard::Student
(ideally with own DB table)
I’ll not go into details of how you might sync up data in those cases as this article is not working with this solution.
So what Bounded Contexts are ultimately trying to achieve is organize code into separate business boundaries:
In order to fetch data or call functionality of different bounded context you would call interfaces of those bounded contexts (you should not call directly classes hidden inside Bounded Context)
You may be asking: “Are Bounded Contexts something like namespaces e.g.:
/admin
or/api
?” No, no they’re not! Think about it this way: “every Bounded Context have its own code for admin e.g.classroom/admin
,public_board/admin
”. It’s the same structure like if you are building microservices (every microservice is own independent application pulling only data needed from other microservices) Microservices are the ultimate respresentation of Bounded Contexts. If you don’t understand what I mean by that please watch talk Web Architecture choices & Ruby(mirror) or Microservices • Martin Fowler
One key benefit of Bounded Contexts is that you can organize your team around different Bounded Contexts, therefore you will have less issues around multiple developers git conflicting each other work.
People are sometimes the most difficult part for programming. No matter what programming pattern your team follows (DCI, SOLID, majestic monolith, …) all it takes is one developer in your team who either don’t get it or decides to do his job differently and all hell breaks loose. So how bounded contexts may help is to introduce some isolation for different styles of coding.
So biggest benefit of hardcore bounded contexts is that your team can split the layers of influence to certain style/pattern of coding. For example you can tell that one colleague to work only around
public_board
bound context while developer/s understanding certain coding principles > will work on theclassroom
bounded context.
Bounded contexts via interface objects
(our solution)
Let me first clarify:
- solution in this article will not introduce any requirements for database split or table split for different models or bounded contexts
- solution that I’ll demonstrate here will not advise to split every Rails application class into separate bounded contexts
- we will also not separate controllers to different bounded contexts
Full explanation why can be found in “Summary” part of the article at the bottom. I’ll also enlist and compare other Rails Bounded Context solutions.
We will rather create more pragmatic bounded contexts only around business logic classes and we will work with same models in same database tables (as would traditional Rails application)
This means we will keep views
, models
,controllers
, as they are in app/views
app/controllers
, app/models
.
We will move only the app/jobs/*
, app/mailers/*
, (and other business logic like app/services/*
)
into bounded contexts.
Therefore we will end up with something like:
app
models
lesson.rb
student.rb
work.rb
teacher.rb
comment.rb
controllers
lessons_controller.rb
works_controller.rb
comments_controller.rb
views
# ...
bounded_contexts
classroom
lesson_creation_service.rb
student_mailer.rb
teacher_mailer.rb
reprocess_work_thumbnail_job.rb
work_upload_service.job
public_board
comment_posted_job.rb
student_mailer.rb
But we will go even further. We will introduce interface objects that will allow us to call related bounded contexts from perspective of the Rails models.
Something like:
teacher = Teacher.find(567)
student = Student.find(123)
student = Student.find(654)
# Lesson creation
lesson = teacher.classroom.create_lesson(students: [student1, student2], title: "Battle of Kursk")
# Student uploads Work file
some_file = File.open('/tmp/some_file.doc')
lesson.classroom.upload_work(student: student1, file: some_file)
# publish lesson
lesson.classroom.publish
# post comment to work
work = Work.find(468)
work.public_board.post_comment(student: student2, title: "Great work mate!")
# Student marks lesson as favorite
lesson.public_board.mark_as_favorite(current_user: student1)
So point is that you have nice boundary interfaces e.g.: lesson.public_board
, lesson.classroom
.
Give it a second to think about how this will clean up your models from business logic. You will end up with lean models and lean controllers
If you ever need to cross different bounded context from within bounded
context you can do that via these interface objects. Have a look at lesson.public_board.cross
calling lesson.classroom.cross_boundary_example
in the code example bellow to fully understand what I mean.
Point is you are able to call different bounded context logic without breaking the
convention of: “Never call different Bounded Context class directly”
Code Example
# db/schema.rb
ActiveRecord::Schema.define(version: 2019_05_22_134007) do
create_table "lessons" do |t|
t.string "title"
t.bigint "teacher_id"
t.boolean "published", default: false
end
create_table "students" do |t|
t.string "email"
end
create_table "students_lessons" do |t|
t.bigint "lesson_id"
t.bigint "student_id"
end
create_table "teachers" do |t|
t.string "email"
end
create_table "works" do |t|
t.bigint "lesson_id"
t.bigint "student_id"
end
create_table "comments" do |t|
t.bigint "work_id"
t.bigint "student_id"
t.string "content"
end
end
# app/models/students.rb
class Student < ActiveRecord::Base
has_many :works
has_many :comments
has_and_belongs_to_many :lessons
end
# app/models/teacher.rb
class Teacher < ActiveRecord::Base
has_many :lessons
def classroom
@classroom ||= Classroom::TeacherInterface.new(self)
end
end
# app/models/lesson.rb
class Lesson < ActiveRecord::Base
belongs_to :teacher
has_many :works
has_and_belongs_to_many :students
def classroom
@classroom ||= Classroom::LessonInterface.new(self)
end
def public_board
@public_board ||= PublicBoard::LessonInterface.new(self)
end
end
# app/models/work.rb
class Work < ActiveRecord::Base
belongs_to :student
belongs_to :lesson
has_many :comments
def classroom
@classroom ||= Classroom::WorkInterface.new(self)
end
end
# app/models/comment.rb
class Comment < ActiveRecord::Base
belongs_to :student
belongs_to :work
end
So far standard Rails stuff, now let’s start introducing Bounded Contexts and their Interface Objects
Bounded Contexts
# config/application.rb
module MyApplication
class Application < Rails::Application
# ...
# We need to tell Rails to include `app/bounded_contexts` in auto loader
# This step is needed only in older Rails applications.
# Any subdirs you put in `./app` should automatically get picked up as an autoload path in newer Rails
config.autoload_paths << Rails.root.join('app', 'bounded_contexts')
# ...
end
end
# app/bounded_contexts/classroom/teacher_interface.rb
module Classroom
class TeacherInterface
attr_reader :teacher
def initialize(teacher)
@teacher = teacher
end
def create_lesson(students:, title:)
Lesson.transaction do
Classroom::LessonCreationService.call(students: students, title: title, teacher: teacher)
end
end
end
end
# app/bounded_contexts/classroom/lesson_interface.rb
module Classroom
class LessonInterface
attr_reader :lesson
def initialize(lesson)
@lesson = lesson
end
def upload_work(student:, file:)
Classroom::WorkUploadService.call(student: student, lesson: lesson, file: file)
end
def publish
lesson.published = true
lesson.save!
end
def cross_boundary_example
# some logic related to classroom bounded context
end
end
end
# app/bounded_contexts/public_board/lesson_interface.rb
module PublicBoard
class LessonInterface
attr_reader :lesson
def initialize(lesson)
@lesson = lesson
end
def mark_as_favorite(current_user:)
# ... some logic
end
def cross
# demonstration of how you can call other bounded context from this bounded context
result = lesson.classroom.cross_boundary_example
# ... you can use the result of different boundary in this boundary
end
end
end
# app/bounded_contexts/public_board/work_interface.rb
module PublicBoard
class WorkInterface
attr_reader :work
def initialize(work)
@work = work
end
def can_post_comment?(current_user:)
work.lesson.published && current_user.is_a?(Student)
end
def post_comment(student:, content:)
comment = @work.comments.create!(student: student, content: content)
PublicBoard::CommentPostedJob.perform_later(comment_id: comment.id)
PublicBoard::StudentMailer.new_comment_on_your_work(comment_id: comment.id).deliver_later
comment
end
end
end
# app/bounded_contexts/classroom/lesson_creation_service.rb
module Classroom
module LessonCreationService
extend self
def call(students:, teacher:, title:)
lesson = Lesson.create!(title: title, teacher: teacher)
students.each do |student|
lesson.students << student
notify_student(student, lesson)
end
lesson
end
private
def notify_student(student, lesson)
Classroom::StudentMailer
.invitation_to_lesson(lesson_id: lesson.id, student_id: student.id)
.deliver_later
end
end
end
# app/bounded_contexts/classroom/student_mailer.rb
class Classroom::StudentMailer < ApplicationMailer
def invitation_to_lesson(lesson_id:, student_id:)
@lesson = Lesson.find_by!(id: lesson_id)
@student = Student.find_by!(id: student_id)
mail(to: @student.email, subject: %{New lesson "#{@lesson.title}"})
end
end
# app/bounded_contexts/classroom/work_upload_service.rb
module Classroom
module WorkUploadService
extend self
def call(student:, lesson:, file:)
work = lesson.works.new(student: student)
work.file = file
work.save!
notify_teacher(work, teacher)
schedule_reprocess_work_thumbnail(work)
work
end
private
def notify_teacher(work, teacher)
Classroom::TeacherMailer
.new_work_uploaded_to_lesson(work_id: work.id, teacher_id: teacher.id)
.deliver_later
end
def schedule_reprocess_work_thumbnail(work)
Classroom::ReprocessWorkThumbnailJob.perform_later(work_id: work.id)
end
end
end
# app/bounded_contexts/classroom/teacher_mailer.rb
module Classroom
class TeacherMailer < ApplicationMailer
def new_work_uploaded_to_lesson(work_id:, teacher_id:)
@work = Work.find_by!(id: work_id)
@teacher = Teacher.find_by!(id: teacher_id)
mail(to: @teacher.email, subject: %{New work was added "#{@work.id}"})
end
end
end
# app/bounded_contexts/classroom/reprocess_work_thumbnail_job.rb
module Classroom
class ReprocessWorkThumbnailJob < ActiveJob::Base
queue_as :classroom
def perform(work_id:)
work = Work.find_by!(id: work_id)
# do something with the work
end
end
end
# app/bounded_contexts/public_board/comment_posted_job.rb
module PublicBoard
class CommentPostedJob < ActiveJob::Base
queue_as :public_board
def perform(comment_id:)
comment = Comment.find_by!(id: comment_id)
# do something with the comment
end
end
end
# app/bounded_contexts/public_board/student_mailer.rb
module PublicBoard
class StudentMailer < ActiveJob::Base
def new_comment_on_your_work(comment_id:)
comment = Comment.find_by!(id: comment_id)
# ...
end
end
end
you can place any types of objects into these bounded contexts like Policy Objects, Query Objects,Serializer Qbjects. Whatever make sense for your application.
In the JSON API application I work for we have policy objects and serializers not in bounded contexts (so in
app/serializers/
app/policy
) because it make sense for us. Point is figure out what is best for you and your team.
controllers
class LessonsController < ApplicationController
# POST /lessons
def create
teacher = Teacher.find(session[:teacher_id])
students = Student.where(id: params[:student_ids])
lesson = teacher.classroom.create_lesson(students: students, title: params[:title])
# ...
end
# POST /lessons/345/publish
def publish
lesson = Lesson.find(params[:lesson_id])
lesson.classroom.publish
# ...
end
# POST /lessons/345/mark_as_favorite
def mark_as_favorite
lesson = Lesson.find(params[:lesson_id])
current_user_student = Student.find(session[:id])
lesson.public_board.mark_as_favorite(current_user: current_user_student)
# ...
end
end
class WorksController < ApplicationController
# POST /lesson/123/works
def create
lesson = Lesson.find(params[:lesson_id])
current_user_student = Student.find(session[:id])
lesson.classroom.upload_work(student: current_user_student, file: params[:file])
# ...
end
end
class CommentsController < ApplicationController
# POST /works/123/comments
def create
work = Work.find(params[:work_id])
current_user_student = Student.find(session[:id])
if work.public_board.can_post_comment?(current_user: current_user_student)
work.work_interaction.post_comment(student: current_user_student, content: params[:content])
# ...
end
end
end
Summary
We (me and my colleagues) are writing backend code for JSON API Rails application following way for over a year (dating since late 2017)
Furthermore I also use this pattern for personal projects in which the server renders HTML (following majestic monolith style of applications)
These applications are quite extensive in business logic. Our main goal is to write application code for long term maintainability, code understandability and team management (as we can organize team members around different bounded contexts).
If you creating small project that will run for couple of months then this may be overkill.
Let me emphasize one more thing: This article took more over a year to be finalize. Behind it are countless hours of learning, comparing and trying solutions. Lot of real team development. Trial and error so you have final form that I’m 100% confident with.
This solution is not “isolated enough”
Yes that is correct. This is not poster child example of Bounded Contexts (You can read about those in section bellow)
Main goal of this solution is to introduce level of organization (code and team) while still keeping the true nature and experience of Ruby on Rails software development
Biggest benefit of Rails is that it is trying to be as friendly as possible to junior (and senior) developers by following certain conventions. And although those conventions may be limiting for large applications they form basis of community and framework that is still dominating in Ruby world for more then 15 years.
Therefore this solution still uses traditional ActiveJob, Mailers, Puma and other vanilla Rails goodies and practices.
Solution is not enforcing new way of thinking around application engine.
This solution is overkill for Rails
I’ve created entire post where I’ll try to convince you that this level of organization is needed. Please read Why you should consider Bounded Contexts in Rails where I’ll go into details.
Don’t extract by default
I advise not to extract to bounded context based businness classe by default!
Start writing your code within controller (as any regular Rails app) and once the complexity grows extract to businness objects (Services, Processor objects, Presenters, Value objects, Listeners …whatever works for you)
Point of this article was to show you how you can organize those business logic classes with more clarity (place them to bounded context and cover it with nice bounded context interface)
Please don’t think of this as a default folder structure from day one of the project
Especially if you are starting with a new project you many not know yet what those business boundaries are.
This is common problem with microcesrvices. Developers introduce boundaries (microservices) that seems logical and then they discover that that split was premature. Same is with Bounded Contexts via Interface Objects (or any other Bounded Contexts) If you prematurely split your code you may find yourself with same problem that you need to join certain bounded context logic. The benefit however is that the refactor is less painful as you are working within same monolithic codebase.
Other ways to do Bounded Contexts in Rails
There are several good resources on Bounded Contexts in Ruby on Rails:
Rails engine based Bounded Contexts
In the article The Modular Monolith: Rails Architecture by Dan Manges you can read about their solution. They’ve achieved bounded contexts by creating Rails Engine for every bounded context
I’ve tried this solution on my personal project I found it bit hard to maintain by myself. Similar to microservices it feels like you are working on several different applications rather than one monolith. I can totally see how this can work really well if the company have large team of developers. But small teams will feel the pain of slow progress.
Drawback of this solution was also that ActiveRecord can use only one database connection (ideally you want own database for every Bounded Context) But good news is that soon Rails 6 will be released featuring multiple db connections
Event Driven architecture
In the book Domain Driven Design in Rails book (paid resource) you can read upon how to achieve independent bounded contexts by following Event Architecture.
to better understand what is event architecture I recommending talk The Many Meanings of Event-Driven Architecture • Martin Fowler
In short you create isolated bounded contexts and then interacting in-between those bounded contexts via publishing events. Check out their ruby gem event store for more info.
Problem is that although I respect and admire this solution it brings quite different way how to think about the whole application as much it’s no longer the good old pragmatic Rails. Arguably many of the benefits of event architecture can be achieved by well designed ActiveJobs but that’s a topic for another article.
Microservices
Like I said microservices are the ultimate representation of Bounded Contexts. I’ve covered this topic in a talk Web Architecture choices & Ruby (youtube mirror)
Or if you want even better talk I’m recommending Microservices talk by Martin Fowler
In general microservices are isolated applications that will just exchange data / call actions in-between each other via HTTP calls. Although they are cool on paper they are super hard to do right, especially if you are small team. But they are great solution if you are Amazon or Google size company.
So in relation to Rails you can crate multiple small Rails (on Sinatra) applications communicating which each other (every Rails application is own bounded context)
Bounded contexts in Phoenix (Elixir)
Other good references explaining Bounded context is Elixir Phoenix 1.3 by Chris McCord - bounded context in Phoenix. It’s about different programming language => Elixir (functional programming lang.)
To me this talk was quite eye-opening in term of how to think of bounded contexts in Monolith. So I definitely recommending to watch few minutes of that talk abound bounded contexts.
It inspired me to dig into new ways of thinking It may inspire you too
Discussion
- https://www.reddit.com/r/rails/comments/bulwga/ruby_on_rails_bounded_contexts_via_interface/
- https://www.reddit.com/r/ruby/comments/bulw5y/ruby_on_rails_bounded_contexts_via_interface/
- http://www.rubyflow.com/p/dp2z58-ruby-on-rails-bounded-contexts-via-interface-objects
Mentioned in:
Mirror:
Entire blog website and all the articles can be forked from this Github Repo