Clean API Example: Save a favorite
Classes you’ll want and need when coding that API
It’s time to see what a Clean API endpoint actually looks like in practice. Per our earlier blog post about the Endpoint Responsibility Checklist, we know that the steps in an execution path in response to an HTTP request are:
🔵Receive→🟢Validate→🔴Validate→🟢Enqueue→🟢Respond
Note the line above — we are going to be repeating it throughout this blog post so we always know where we are.
For write requests, we have the additional steps in response to an enqueued SQS message:
🟢Dequeue→🔴Mutate→🟢Notify
These execution paths map to our architectural diagram:
We are going to walk through each of these paths and show you the classes and details involved in fulfilling each step.
Our examples make heavy use of the meta-programming capabilities of ruby — lots of single lines of code that do a lot under the hood. Don’t worry so much about whether or not your code looks like ours; focus instead on the function being provided and where that function is being offered or implemented in your particular stack.
Receive
🔵Receive→⚪️Validate→️️️⚪️Validate→⚪️Enqueue→⚪️Respond
a. Restful routing
This layer is simple and precise: define an endpoint handler in Sinatra and immediately route it to a controller that enqueues requests.
post '/app/favorite' do
enqueue_request Controllers::Favorites::Create
end
b. Control flow
We have defined the method enqueue_request
(and execute_request
, used for read operations). The enqueue_request call will attempt to put a new job into our SQS processing queue. These are simply special methods we have defined, similar to a DSL, that implement the described operation with additional error handling using dry-monads.
The line above passes the request to a Controller
class, which is another custom class of ours. That controller further uses a DSL to specify request, service, and presenter classes. Below you will see the highly condensed, two-step control flow:
module Controllers
module Favorites
class Create < Base::Controller
request Requests::Favorites::Create
service Services::Favorites::Create
end
end
end
Validate (Syntactic)
⚪️Receive→🟢Validate→️️️⚪️Validate→⚪️Enqueue→⚪️Respond
a. Parameter extraction and coercion (Request class)
We have a specialized Request
object that performs parameter extraction and coercion. Once again, this is a custom class we have defined. Each request is passed the params
, request
and env
variables that are common to all Rack-based frameworks.
Our Request class extracts 3 pieces of data from the params:
extracts :profile_id,
:target_id,
:folder_id
Pulling data out of the parameters hash and converting it to properly-typed values is such a common task that we have defined a formal class for this function.
The extracts keyword will automatically trigger extraction for the type specified.
Here is what a parameter extractor (another one of our custom classes) for a locale looks like:
module Params
class Locale < Base::Param
def extract
@extract ||= params[:locale].presence&.clean_locale&.split('@')&.first
end
end
end
b. Authentication (Request class)
We have a param extractor that takes authentication information supplied in the request and uses it to lookup a profile_id
. This is forms an implicit Authentication step.
c. Syntactic validation (Request class)
The request class syntactically validates our parameters:
validates target_id: Validators::Profiles::Id
All Validator
classes accept one or more inputs, and all respond to the .valid?
method. Validators are again one of our custom classes. Validators do not have access to our application or domain; they instead are able to provide syntactic validation of the data supplied. For example, here is what the Validators::Profiles::Id class looks like:
def valid?
profile_id.present? && profile_id?
endprivateattr_reader :profile_iddef profile_id?
profile_id.to_i.positive? &&
profile_id.to_s.match?(REGEX_VALID_PROFILE_ID)
end
What is the difference between extraction and validation?
Extraction is like calling .to_i
, and returns a value. Validation evaluates the value and returns a boolean.
d. Collate params (Request class)
Our request class returns the 3 exact properties required by the subsequent Service: creator_id
, target_id
and folder_id
:
returns :creator_id, :target_id, :folder_id
Request class end-to-end
Here is what a Request class can look like end-to-end, using our special DSL:
module Requests
module Favorites
class Create < Base::Request
extracts :profile_id,
:target_id,
:folder_id validates profile_id: Validators::Profiles::Id
validates target_id: Validators::Profiles::Id returns :profile_id,
:target_id,
:folder_id
end
Validate (Semantic)
⚪️Receive→⚪️Validate→️️️🔴Validate→⚪️Enqueue→⚪️Respond
As a reminder, here is our control flow:
module Controllers
module Favorites
class Create < Base::Controller
request Requests::Favorites::Create
service Services::Favorites::Create
end
end
end
At this point we have finished execution with the Request class and moved on to the Service
class:
service Services::Favorites::Create
The Service
is another one of our custom classes that is instantiated by the Controller with only the inputs it needs to function, which come from the returns directive of the Request class above. Before enqueuing, the service level validations are run, thereby providing us with semantic validation as well.
Here is what the service class looks like:
module Services
module Favorites
class Create < Base::Service
attr_reader :creator_id, :target_id, :folder_id validates_required :creator, :target
validates_with Validators::Profiles::Active, :creator, :target
validates :validate_creator_can_favorite
The validator Validators::Favorites::CreatorCanFavorite
checks that the folder exists and that the originator hasn’t exceeded the maximum number of favorites according to their Free or Pro status.
validates :validate_creator_can_favoritedef validate_creator_can_favorite
Validators::Favorites::CreatorCanFavorite.new(
creator: creator,
folder_id: folder_id
).validate
end
Enqueue
⚪️Receive→⚪️Validate→️️️⚪️Validate→🟢Enqueue→⚪️Respond
Once all Service validations have passed, the Controller creates a JobConfig
containing the same inputs that were given to the service and a job type, then enqueues it to SQS.
Respond
⚪️Receive→⚪️Validate→️️️⚪️Validate→⚪️Enqueue→🟢Respond
Once we have completed our enqueue step, we are finished.
Because we have not specified a special response, we use a default JsonResponse
that will return 200 to the client.
Part 2: Request processing
The DBWriter Queue Processor runs on a special fleet of servers in production and follows the following execution path:
🟢Dequeue→🔴Mutate→️️️🟢Notify
🟢 Dequeue
JobConfigs
are constantly being pulled off the queue and processed. The DBWriter routes the JobConfig to the correct code path for background processing, in this case, Services::Favorites::Create
.
🔴 Mutate
We finally reach the part of our code that does the work! Our service classes define a series of imperative steps that (presumably) mutate state, for example
def steps
%i[
create_favorite
]
enddef create_favorite
@result =
favorites_respository.create(target_id: target_id,
folder_id: folder_id).favorite
end
🟢 Notify
For many write endpoints, we relay a socket message to the client to notify the user that the operation has completed. Sometimes this appears as a toast in the app, and sometimes no feedback is necessary.
All done! 🎉
At this point we have completed our write operation. Though it may seem like a lot, remember that a write endpoint has 2 execution paths with 5 and 3 steps, respectively, all to fulfill the 14 separate functions we have determined an endpoint must fulfill.
We’ve defined several classes that have generic-sounding names but are custom to our implementation:
Request
Controller
Service
JobConfig
Extractor
Validator
We’ve avoided getting too far into specifics about how these are implemented, because each language/framework will have its own opinion. What will be important to you is that you consider the execution path your API will follow and where/when each of these functions is being performed.
You may have a use case or scenario that is easier, but remember, in the absence of explicit classes and rules, these choices are either assumed, implied or ignored, but they cannot be avoided.
Though you may think these considerations are unnecessary, as your API grows eventually you will need to handle all of the cases we have outlined.
A note about error handling
Thus far we have avoided much discussion about error handling, but rest assured this is a critical part of our (and any) architecture. We use dry-monads as we discussed in our post. We may expand on how we use dry-monads in a future post, but otherwise we recommend reading the following blog post for more about how dry-monads can improve your service design.
More in this series
- A visual history of web API design
- Patterns of web API execution flows
- Railway Oriented Programming
- Clean API Architecture
- Endpoint Responsibility Checklist
- Example: Saving a favorite ← you are here
Other series you might like
Android Activity Lifecycle considered harmful (2021)
Android process death, unexplainable NullPointerExceptions, and the MVVM lifecycle you need right now
Kotlin in Xcode? Swift in Android Studio? (2020)
A series on using Clean + MVVM for consistent architecture on iOS & Android
One more thing…
If you’re a mobile developer who enjoys app architecture, are interested in working for a values-first app company serving the queer community, talk to us! Visit https://www.scruff.com/careers for info about jobs at Perry Street Software.
About the author
Eric Silverberg is CEO of Perry Street Software, publisher of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 20M members worldwide.