Clean API Example: Save a favorite

Classes you’ll want and need when coding that API

Eric Silverberg
Perry Street Software Engineering
8 min readJun 9, 2021

--

Do you need all these classes? Do cookies need chocolate chips? YES! Photo by Mae Mu on Unsplash

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.

Practice & you will have the same confidence with this pattern

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

What it looks like when your webserver runs out of processing threads

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 Controllerclass, 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:

The hay is environment variable
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:

We do the same to our params
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.

Except we use a database not a fist

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?
end
privateattr_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.

Don’t assume your params are valid. Ask Dwight first.

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
What we do to our data before sending to the next layer

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.

Syntactic validation: are they words; Semantic validation: are they sentences?

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
What service-level validators do if you exceed a limit

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.

What we do with write requests

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.

Errors get handled by Oscar the Grouch

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.

Hopefully our SQS queue only has a few books on the shelf

🔴 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

When RDS is having issues it feels like
def steps
%i[
create_favorite
]
end
def 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.

What happens when your client has a bug in its socket message parser

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.

What eventually happens when coding without architecture

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.

I promise monads are a big deal

More in this series

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.

--

--