Clean Architecture
This gem provides helper interfaces and classes to assist in the construction of application with Clean Architecture, as described in Robert Martin's seminal book.
Table of Contents
Generated by https://github.com/ekalinin/github-markdown-toc/blob/master/gh-md-toc
- Installation
- Philosophy
- Structure
- Conventions
- Helper classes
Installation
Add this line to your application's Gemfile:
gem 'clean-architecture'
And then execute:
$ bundle install
$ bundle binstubs clean-architecture
Philosophy
The intention of this gem is to help you build applications that are built from the use case down, and decisions about I/O can be deferred until the last possible moment. It relies heavily on the duckface-interfaces gem to enforce interface implementation.
Screaming architecture - use cases as an organisational principle
Uncle Bob suggests that your source code organisation should allow developers to easily find a listing of all use cases your application provides. Here's an example of how this might look in a Rails application.
- lib
- my_banking_application
- use_cases
- retail_customer_opens_bank_account.rb
- retail_customer_makes_a_deposit.rb
- ...
Note that the use case name contains:
- the user role
- the action
- the (sometimes implied) subject
Design principles
SRP - The Single Responsibility principle
A function should do one, and only one, thing
We satisfy the SRP by following these rules:
- An adapter is solely responsible for presenting the properties of a business object, or a small number of business objects, in a known interface
- A command is solely responsible for completing an atomic I/O operation
- An entity is solely responsible for representing, in memory, a business object whos properties do not come from a single source
- An interface is a module that represents a contract between two classes
- A serializer is solely responsible for taking a business object and turning it into a representation made up of purely primitive values
- A strategy is an algorithm used by commands to compose atomic I/O operations
- A use case is solely responsible for checking whether an actor has permissions to perform a command, and executing that command if so
- A validator is solely responsible for validating a business object and returning a validation result
OCP - The Open/Closed Principle, LSP - The Liskov Substitution Principle and DIP - The Dependency Inversion Principle
A software artefact should be open for extension but closed for modification
A caller should not have to know the type of an object to interact with it
Always depend on or derive from a stable abstraction, rather than a volatile concrete class
We satisfy the OCP, LSP & DIP by following these rules:
- We create a clean boundary between our business logic, our gateway and our application-specific classes using interfaces
- We use interfaces wherever possible, allowing concrete implementations of those interfaces to be extended without breaking the contract
- We write unit tests against interfaces, never against concrete implementations (unless interfaces don't exist)
ISP - The Interface Segregation Principle
Where some actors only use a subset of methods available from an interface, the interface should be split into sub-interfaces supporting each type of caller
We satisfy the ISP by following these rules:
- Each functional area of our code is split into folders (under
lib
in Rails projects) - Each functional area defines its own interfaces
- Interfaces are not shared between functional areas
Component cohesion
REP - The Reuse/Release Equivalence Principle, CCP - The Common Closure Principle & CRP - The Common Reuse Principle
Classes and modules that are grouped together into a component should be releasable together
Gather into components those changes the change for the same reasons and at the same times.
Classes and modules that tend to be reused together should be placed in the same component
We satisfy the REP, CCP and CRP by:
- Having team discussions whenever we make decisions about what a new functional area should be called and what it should contain
- Ensuring that none of our functional areas make direct reference back to the parent application
- Splitting functional areas out into gems when those functional areas change at a different rate than the rest of the codebase
- Splitting functional areas out into standalone applications when it makes sense to do so
Component coupling
ADP - The Acyclic Dependencies Principle
Don't create circular dependencies
I don't think I need to explain this. Just don't do it. I like explicitly including dependencies using require
because it actually prevents you from doing this. Rails, in so many ways, makes one lazy.
SDP - The Stable Dependencies Principle
A component always have less things depending on it than it depends on
We satisfy the SDP by:
- Putting sensible abstractions in place that adhere to the Single Responsibility principle
- Not sharing abstractions and entities between multiple functional areas
SAP - The Stable Abstractions Principle
A component should be as abstract as it is stable
We satisfy the SAP by:
- Thinking hard about the methods and parameters we specify in our interfaces. Are they solving for a general problem? Are we likely to have to change them when requirements change, and how we can avoid that?
Structure
Practical suggestions for implementation
The code that manages your inputs (e.g. a Rails controller) instantiates a gateway object
The code that manages your inputs (e.g. a Rails controller) instantiates a use case actor object
- Suggest: a class that implements the
UseCaseActor
interface
- Suggest: a class that implements the
use_case_actor = MyUseCaseActorAdapter.new(devise_current_user)
- The code that manages your inputs (e.g. a Rails controller) instantiates a use case input port
object
- Suggest: a class that implements the
BaseParameters
interface - Suggest: implement the
AuthorizationParameters
interface if you want to make authorization part of your use case logic - Suggest: implement the
TargetedParameters
if your use case operates on a single object - Suggest: use the
TargetedParameters
entity for an out-of-the-box class that gives you all of these
- Suggest: a class that implements the
input_port = CleanArchitecture::Entities::TargetedParameters.new(
use_case_actor,
TargetActiveRecordClass.find(params[:id]),
strong_params,
gateway,
other_settings_hash
)
- The code that manages your inputs (e.g. a Rails controller) instantiates a use case object
- Suggest: a class that implements the
UseCase
interface
- Suggest: a class that implements the
use_case = MyBankingApplication::UseCases::RetailCustomerMakesADeposit.new(input_port)
Conventions
Result objects
We make use of the Dry-Rb collection of Gems to
provide better control flow instead of relying on raise
and rescue
.
Specifically, we use:
Idiomatic FP
Multiple bind
operations
When you want to bind or chain multiple method calls using the previous return value, consider using Do Notation
This is inspired by the Haskell do-notation which lets you go from writing this:
action1
>>=
(\ x1 -> action2
>>=
(\ x2 -> mk_action3 x1 x2 ))
to this:
do
x1 <- action1
x2 <- action2
mk_action3 x1 x2
Transactions
If you don't want to manually handle the wiring between multiple Success/Failure objects, you can use the dry-transaction gem which abstracts this away so that you just need to define steps, and deal with the input from the output of the previous result.
require "dry/transaction"
class CreateUser
include Dry::Transaction
step :validate
step :create
private
def validate(input)
# returns Success(valid_data) or Failure(validation)
end
def create(input)
# returns Success(user)
end
end
Helper classes
The gem comes with some useful classes that can help you achieve a cleaner architecture with less work.
Active Record Entity Builder
Maintain a separation between your business entities and your database requires the use of gateways that build your entities from records in the database.
For Rails applications using ActiveRecord this can involve a bunch of boilerplate code where your simply creating a hash from the attributes of the database record & using those to create a new instance of your struct based entity.
The CleanArchitecture::Builders::AbstractActiveRecordEntityBuilder
can help remove this boilerplate by handling 99% of the mapping for you.
Usage:
Create a builder class and have it inherit from CleanArchitecture::Builders::AbstractActiveRecordEntityBuilder
, from here you need to point the builder at the entity you wish for it to create instances of with .acts_as_builder_for_entity
, from there its just a case of instantiating the builder with an instance of your AR model and calling #build
.
Relations are handled easily, just define a builder for said entity and then declare the relation with has_many :relation_name, use: MyBuilderClass
and belongs_to :relation_name, use: MyBuilderClass
.
If you wish to override the attributes used to construct the entity you can define a #attributes_for_entity
method with said attributes in a hash, this can be useful for complex relations, files and other attributes that don't map perfectly from the database to your struct based entity.
class Person < ApplicationRecord
has_many :interests, autosave: true, dependent: :destroy
belongs_to :father
end
class Entities::Person < Dry::Struct
attribute :forename, Types::Strict::String
attribute :surname, Types::Strict::String
attribute :father, Types.Instance(Person)
attribute :interests, Types.Array(Types.Instance(Interest))
attribute :birth_month, Types::Strict::String
end
class PersonBuilder < CleanArchitecture::Builders::AbstractActiveRecordEntityBuilder
acts_as_builder_for_entity Entities::Person
has_many :interests, use: InterestBuilder
belongs_to :father, use: PersonBuilder
def attributes_for_entity
{ birth_month: @ar_model_instance.birth_date.month }
end
end
Use cases with contracts, errors & form objects
Finding a way to map HTTP parameters to parameters within your use case, pass back & display validation errors and coerce types are difficult to replace when moving away from the typical MyMode.update(params.permit(:some_param))
that a standard Rails app might use.
The CleanArchitecture::UseCases
component contains some useful classes for helping to make replacing these functions a little easier whilst still maintaining good boundaries.
The 'contracts' use dry-validation
and support all options included the sharing of contracts between use cases, more information can be found here: https://dry-rb.org/gems/dry-validation/. Don't be afraid of the seemingly magical .contract
method that use cases have, all its doing is creating an anonymous Class
and storing it in a class variable, the methods existence is justified by how it enables form objects & helps to standardise the process a little.
dry-validation
itself is actually built on top of dry-schema
, as such most of the useful information on predicates can be found here: https://dry-rb.org/gems/dry-schema/basics/built-in-predicates/
Usage:
Usage is fairly simple, use cases define a contract, parameters handed to a use case are validated, at which point if the parameters aren't valid you'll get an Errors
object back within a Failure
, if they are you'll get a success with a Parameters
.
Here is an example use case for a user updating their username that does a pre-flight check to ensure the username is available:
module MyBusinessDomain
module UseCases
class UserUpdatesNickname < CleanArchitecture::UseCases::AbstractUseCase
contract do
option :my_gateway_object
params do
required(:user_id).filled(:id)
required(:nickname).filled(:str)
end
rule(:nickname).validate(:not_already_taken)
register_macro(:not_already_taken) do
unless my_gateway_object.username_is_available?(values[key_name])
key.failure('is already taken')
end
end
end
extend Forwardable
include Dry::Monads::Do.for(:result)
def result
valid_params = yield result_of_validating_params
context(:my_gateway_object).result_of_updating_nickname(
valid_params[:id],
valid_params[:nickname]
)
end
end
end
end
You could imagine a page with a simple form asking the user to enter their new username and you may want this form to display that message if the username isn't available. The Form
class can be used to assist with the mapping of http parameters to the use case parameters. Best of all since the forms aren't tied to the use cases they can live within your web app far away from your business logic.
module MyWebApp
class NicknameUpdateForm < CleanArchitecture::UseCases::Form
acts_as_form_for MyBusinessDomain::UseCases::UserUpdatesNickname
end
end
The standard Rails form builder works with instances of Form
.
Putting these both together a controller action would look like the below example.
- A new instance of the use case is passed a parameter object built from
params
. - If the use case is successful we'll show a flash message.
- If unsuccessful we'll take the returned
Errors
(Entities::FailureDetails
and plain strings are also handled by#with_errors
) and add them to the form with#with_errors
and re-render theedit
action.
module MyWebApp
class NicknamesController < ApplicationController
def update
Dry::Matcher::ResultMatcher.call(user_updates_nickname.result) do |matcher|
matcher.success do |_|
flash[:success] = 'Nickname successfully updated'
redirect_to action: :edit
end
matcher.failure do |errors|
@form = nickname_update_form.with_errors(errors)
render :edit
end
end
end
private
def user_updates_nickname
MyBusinessDomain::UseCases::UserUpdatesNickname.new(nickname_update_form.to_parameter_object)
end
def nickname_update_form
@nickname_update_form ||= NicknameUpdateForm.new(
params: params.permit(:user_id, :nickname),
context: { my_gateway_object: MyGateway.new }
)
end
end
end
There won't always be a complex form in front of a use case, sometimes its just one parameter, using the above example example you could easily execute the use case with a manually constructed parameter object if it was say an API only endpoint:
module MyWebApp
class NicknamesController < ApplicationController
def update
Dry::Matcher::ResultMatcher.call(user_updates_nickname.result) do |matcher|
matcher.success do |_|
render json: { success: true }
end
matcher.failure do |errors|
render json: { errors: errors. }
end
end
end
private
def user_updates_nickname
MyBusinessDomain::UseCases::UserUpdatesNickname.new(user_updates_nickname_parameters)
end
def user_updates_nickname_parameters
MyBusinessDomain::UseCases::UserUpdatesNickname.parameters(
context: { my_gateway_object: MyGateway.new },
user_id: params[:user_id],
nickname: params[:nickname]
)
end
end
end
Elements of contracts can be shared amongst use cases, this can be very helpful for options
(context) that you know every use case in a domain may require or validation rules that you know will be used in multiple use cases. Shared contracts can help tidy up your specs too by allowing you to test all your validation logic separately to what the use case itself does.
module MyBusinessDomain
module UseCases
class SharedContract < CleanArchitecture::UseCases::Contract
option :my_gateway_object
register_macro(:not_already_taken?) do
unless not_already_taken?(values[key_name])
key.failure('is already taken')
end
end
private
def not_already_taken?(username)
my_gateway_object.username_is_available?(values[key_name])
end
end
end
end
Using a shared contract is simple; when you define the contract for a use case just specify the shared contract as an argument to .contract
:
module MyBusinessDomain
module UseCases
class UserUpdatesNickname < CleanArchitecture::UseCases::AbstractUseCase
contract(SharedContract) do
option :my_gateway_object
params do
required(:user_id).filled(:id)
required(:nickname).filled(:str)
end
rule(:nickname).validate(:not_already_taken)
Use cases themselves are outside of their params just plain old ruby objects. There are only a few methods you'll use composing use cases:
#result_of_validating_params
This methods gives you a Result monad with either Success
containing a hash of the valid params or Failure
with an Errors
instance containing the validation errors. The Do
syntax from dry-monads
helps to tidy the usage of this method up:
module MyBusinessDomain
module UseCases
class UserUpdatesAge < CleanArchitecture::UseCases::AbstractUseCase
contract do
params do
required(:user_id).filled(:int)
required(:age).filled(:int)
end
end
include Dry::Monads::Do.for(:result)
def result
valid_params = yield result_of_validating_params
Dry::Monads::Success(valid_params[:age] * 365)
end
end
end
end
#context
Any context variables defined as option
's in your use case contract have to be specified whenever creating an instance of the parameter objects for your use case. In practice this means you can't accidentally forget to pass in say a gateway object / repository / factory / etc.
These context variables can be used within the use case using the context
method:
module MyBusinessDomain
module UseCases
class UserUpdatesAge < CleanArchitecture::UseCases::AbstractUseCase
contract do
option :required_gateway_object
params do
required(:user_id).filled(:int)
required(:age).filled(:int)
end
end
include Dry::Monads::Do.for(:result)
def result
valid_params = yield result_of_validating_params
context(:required_gateway_object).update_user_age_result(
valid_params[:user_id],
valid_params[:age]
)
end
end
end
end
You may wish to tidy access to context variables away into private methods to mask the implementation details.
#fail_with_error_message
This method can be used for returning a simple message wrapped in an instance of Errors
. Optionally you can specify the type of error should you wish for your controller to react different for say a record not being found vs an API connection error.
module MyBusinessDomain
module UseCases
class UserUpdatesChristmasWishlist < CleanArchitecture::UseCases::AbstractUseCase
contract do
option :required_gateway_object
params do
required(:user_id).filled(:int)
required(:most_wanted_gift).filled(:str)
end
end
include Dry::Monads::Do.for(:result)
CHRISTMAS_DAY = Date.new('2019', '12', '25')
def result
valid_params = yield result_of_validating_params
if Date.today == CHRISTMAS_DAY
return ('Uh oh, Santa has already left the North Pole!')
end
context(:required_gateway_object).change_most_wanted_gift(user_id, most_wanted_gift)
end
end
end
end